diff --git a/src/lib/core/resource/discovery/PackageDiscovery.js b/src/lib/core/resource/discovery/PackageDiscovery.js index b1c689a..5d5b920 100644 --- a/src/lib/core/resource/discovery/PackageDiscovery.js +++ b/src/lib/core/resource/discovery/PackageDiscovery.js @@ -86,13 +86,20 @@ class PackageDiscovery extends BaseDiscovery { */ async _loadStaticRegistry() { const packageRoot = await this._findPackageRoot() - const registryPath = path.join(packageRoot, 'src', 'resource.registry.json') - - if (!await fs.pathExists(registryPath)) { - throw new Error('Static registry file not found') + + // 尝试主要路径:src/resource.registry.json + const primaryPath = path.join(packageRoot, 'src', 'resource.registry.json') + if (await fs.pathExists(primaryPath)) { + return await fs.readJSON(primaryPath) } - return await fs.readJSON(registryPath) + // 尝试后备路径:resource.registry.json + const alternativePath = path.join(packageRoot, 'resource.registry.json') + if (await fs.pathExists(alternativePath)) { + return await fs.readJSON(alternativePath) + } + + throw new Error('Static registry file not found') } /** @@ -146,6 +153,86 @@ class PackageDiscovery extends BaseDiscovery { return await this.fileScanner.scanResourceFiles(baseDir, resourceType) } + /** + * 检测执行环境类型 + * @returns {Promise} 环境类型:development, npx, local, unknown + */ + async _detectExecutionEnvironment() { + // 1. 检查是否在开发环境 + if (await this._isDevelopmentMode()) { + return 'development' + } + + // 2. 检查是否通过npx执行 + if (this._isNpxExecution()) { + return 'npx' + } + + // 3. 检查是否在node_modules中安装 + if (this._isLocalInstallation()) { + return 'local' + } + + return 'unknown' + } + + /** + * 检查是否在开发模式 + * @returns {Promise} 是否为开发模式 + */ + async _isDevelopmentMode() { + const cwd = process.cwd() + const hasCliScript = await fs.pathExists(path.join(cwd, 'src', 'bin', 'promptx.js')) + const hasPackageJson = await fs.pathExists(path.join(cwd, 'package.json')) + + if (!hasCliScript || !hasPackageJson) { + return false + } + + try { + const packageJson = await fs.readJSON(path.join(cwd, 'package.json')) + return packageJson.name === 'dpml-prompt' + } catch (error) { + return false + } + } + + /** + * 检查是否通过npx执行 + * @returns {boolean} 是否为npx执行 + */ + _isNpxExecution() { + // 检查环境变量 + if (process.env.npm_execpath && process.env.npm_execpath.includes('npx')) { + return true + } + + // 检查目录路径(npx缓存目录) + const currentDir = this._getCurrentDirectory() + if (currentDir.includes('.npm/_npx/') || currentDir.includes('_npx')) { + return true + } + + return false + } + + /** + * 检查是否在本地安装 + * @returns {boolean} 是否为本地安装 + */ + _isLocalInstallation() { + const currentDir = this._getCurrentDirectory() + return currentDir.includes('node_modules/dpml-prompt') + } + + /** + * 获取当前目录(可以被测试mock) + * @returns {string} 当前目录路径 + */ + _getCurrentDirectory() { + return __dirname + } + /** * 查找包根目录 * @returns {Promise} 包根目录路径 @@ -157,9 +244,23 @@ class PackageDiscovery extends BaseDiscovery { return cached } - const packageRoot = await this._findPackageJsonWithPrompt() + const environment = await this._detectExecutionEnvironment() + let packageRoot = null + + switch (environment) { + case 'development': + packageRoot = await this._findDevelopmentRoot() + break + case 'npx': + case 'local': + packageRoot = await this._findInstalledRoot() + break + default: + packageRoot = await this._findFallbackRoot() + } + if (!packageRoot) { - throw new Error('Package root with prompt directory not found') + throw new Error('Package root not found') } this.setCache(cacheKey, packageRoot) @@ -167,40 +268,77 @@ class PackageDiscovery extends BaseDiscovery { } /** - * 查找包含prompt目录的package.json + * 查找开发环境的包根目录 * @returns {Promise} 包根目录路径或null */ - async _findPackageJsonWithPrompt() { - let currentDir = __dirname + async _findDevelopmentRoot() { + const cwd = process.cwd() + const hasPackageJson = await fs.pathExists(path.join(cwd, 'package.json')) + const hasPromptDir = await fs.pathExists(path.join(cwd, 'prompt')) - while (currentDir !== path.parse(currentDir).root) { - const packageJsonPath = path.join(currentDir, 'package.json') - const promptDirPath = path.join(currentDir, 'prompt') + if (!hasPackageJson || !hasPromptDir) { + return null + } - // 检查是否同时存在package.json和prompt目录 - const [hasPackageJson, hasPromptDir] = await Promise.all([ - fs.pathExists(packageJsonPath), - fs.pathExists(promptDirPath) - ]) - - if (hasPackageJson && hasPromptDir) { - // 验证是否是PromptX包 - try { - const packageJson = await fs.readJSON(packageJsonPath) - if (packageJson.name === 'promptx' || packageJson.name === 'dpml-prompt') { - return currentDir - } - } catch (error) { - // 忽略package.json读取错误 - } + try { + const packageJson = await fs.readJSON(path.join(cwd, 'package.json')) + if (packageJson.name === 'dpml-prompt') { + return fs.realpathSync(cwd) // 解析符号链接 } - - currentDir = path.dirname(currentDir) + } catch (error) { + // Ignore JSON parsing errors } return null } + /** + * 查找已安装包的根目录 + * @returns {Promise} 包根目录路径或null + */ + async _findInstalledRoot() { + try { + const currentDir = this._getCurrentDirectory() + let searchDir = currentDir + + // 向上查找package.json + while (searchDir !== path.parse(searchDir).root) { + const packageJsonPath = path.join(searchDir, 'package.json') + + if (await fs.pathExists(packageJsonPath)) { + const packageJson = await fs.readJSON(packageJsonPath) + + if (packageJson.name === 'dpml-prompt') { + return searchDir + } + } + + searchDir = path.dirname(searchDir) + } + } catch (error) { + // Ignore errors + } + + return null + } + + /** + * 后备方案:使用模块解析查找包根目录 + * @returns {Promise} 包根目录路径或null + */ + async _findFallbackRoot() { + try { + const resolve = require('resolve') + const packageJsonPath = resolve.sync('dpml-prompt/package.json', { + basedir: process.cwd() + }) + return path.dirname(packageJsonPath) + } catch (error) { + return null + } + } + + /** * 生成包引用路径 * @param {string} filePath - 文件绝对路径 diff --git a/src/tests/core/resource/ResourceDiscovery.unit.test.js b/src/tests/core/resource/ResourceDiscovery.unit.test.js index 42c9e42..b638a31 100644 --- a/src/tests/core/resource/ResourceDiscovery.unit.test.js +++ b/src/tests/core/resource/ResourceDiscovery.unit.test.js @@ -269,7 +269,7 @@ describe('ResourceDiscovery', () => { 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 + expect(glob).toHaveBeenCalledTimes(8) // 2 valid paths × 4 resource types (role, execution, thought, knowledge) }) }) diff --git a/src/tests/core/resource/ResourceManager.unit.test.js b/src/tests/core/resource/ResourceManager.unit.test.js index 8103f03..86fad64 100644 --- a/src/tests/core/resource/ResourceManager.unit.test.js +++ b/src/tests/core/resource/ResourceManager.unit.test.js @@ -114,6 +114,7 @@ describe('ResourceManager - Unit Tests', () => { const result = await manager.loadResource('java-backend-developer') expect(result).toEqual({ + success: true, content: mockContent, path: '/resolved/path/java.role.md', reference: '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md' @@ -144,15 +145,17 @@ describe('ResourceManager - Unit Tests', () => { }) test('应该处理资源未找到错误', async () => { - await expect(manager.loadResource('non-existent-role')) - .rejects.toThrow("Resource 'non-existent-role' not found") + const result = await manager.loadResource('non-existent-role') + expect(result.success).toBe(false) + expect(result.message).toBe("Resource 'non-existent-role' not found") }) 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') + const result = await manager.loadResource('java-backend-developer') + expect(result.success).toBe(false) + expect(result.message).toBe('Protocol resolution failed') }) test('应该处理文件读取失败', async () => { @@ -161,8 +164,9 @@ describe('ResourceManager - Unit Tests', () => { throw new Error('File not found') }) - await expect(manager.loadResource('java-backend-developer')) - .rejects.toThrow('File not found') + const result = await manager.loadResource('java-backend-developer') + expect(result.success).toBe(false) + expect(result.message).toBe('File not found') }) }) diff --git a/src/tests/core/resource/discovery/PackageDiscovery.environment.integration.test.js b/src/tests/core/resource/discovery/PackageDiscovery.environment.integration.test.js new file mode 100644 index 0000000..4daeafd --- /dev/null +++ b/src/tests/core/resource/discovery/PackageDiscovery.environment.integration.test.js @@ -0,0 +1,299 @@ +const PackageDiscovery = require('../../../../lib/core/resource/discovery/PackageDiscovery') +const fs = require('fs-extra') +const path = require('path') +const tmp = require('tmp') + +describe('PackageDiscovery Environment Detection Integration', () => { + let discovery + let originalCwd + let originalEnv + let originalExecPath + + beforeEach(() => { + discovery = new PackageDiscovery() + originalCwd = process.cwd() + originalEnv = process.env.NODE_ENV + originalExecPath = process.env.npm_execpath + }) + + afterEach(() => { + process.chdir(originalCwd) + process.env.NODE_ENV = originalEnv + process.env.npm_execpath = originalExecPath + }) + + describe('Environment Detection', () => { + test('should detect development environment', async () => { + // Mock development environment indicators + jest.spyOn(fs, 'pathExists') + .mockResolvedValueOnce(true) // src/bin/promptx.js exists + .mockResolvedValueOnce(true) // package.json exists + + jest.spyOn(fs, 'readJSON').mockResolvedValue({ + name: 'dpml-prompt' + }) + + const environment = await discovery._detectExecutionEnvironment() + expect(environment).toBe('development') + }) + + test('should detect npx execution via environment variable', async () => { + process.env.npm_execpath = '/usr/local/bin/npx' + + // Mock non-development environment + jest.spyOn(fs, 'pathExists').mockResolvedValue(false) + + const environment = await discovery._detectExecutionEnvironment() + expect(environment).toBe('npx') + }) + + test('should detect npx execution via directory path', async () => { + // Mock _getCurrentDirectory to simulate npx cache directory + jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue('/home/user/.npm/_npx/abc123/node_modules/dpml-prompt') + jest.spyOn(fs, 'pathExists').mockResolvedValue(false) + + const environment = await discovery._detectExecutionEnvironment() + expect(environment).toBe('npx') + }) + + test('should detect local installation', async () => { + // Mock _getCurrentDirectory to simulate node_modules installation + jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue('/project/node_modules/dpml-prompt/src/lib/core/resource/discovery') + jest.spyOn(fs, 'pathExists').mockResolvedValue(false) + + const environment = await discovery._detectExecutionEnvironment() + expect(environment).toBe('local') + }) + + test('should return unknown for unrecognized environment', async () => { + jest.spyOn(fs, 'pathExists').mockResolvedValue(false) + + const environment = await discovery._detectExecutionEnvironment() + expect(environment).toBe('unknown') + }) + }) + + describe('Package Root Finding - Development Environment', () => { + test('should find package root in development mode', async () => { + // Setup development environment + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const projectRoot = tempDir.name + + // Create development structure + await fs.ensureDir(path.join(projectRoot, 'src', 'bin')) + await fs.ensureDir(path.join(projectRoot, 'prompt')) + await fs.writeJSON(path.join(projectRoot, 'package.json'), { + name: 'dpml-prompt', + version: '1.0.0' + }) + await fs.writeFile(path.join(projectRoot, 'src', 'bin', 'promptx.js'), '// CLI entry') + + process.chdir(projectRoot) + + const packageRoot = await discovery._findDevelopmentRoot() + // Use fs.realpathSync to handle symlinks and path resolution consistently + expect(fs.realpathSync(packageRoot)).toBe(fs.realpathSync(projectRoot)) + }) + + test('should return null if not dpml-prompt package', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const projectRoot = tempDir.name + + await fs.ensureDir(path.join(projectRoot, 'src', 'bin')) + await fs.ensureDir(path.join(projectRoot, 'prompt')) + await fs.writeJSON(path.join(projectRoot, 'package.json'), { + name: 'other-package', + version: '1.0.0' + }) + + process.chdir(projectRoot) + + const packageRoot = await discovery._findDevelopmentRoot() + expect(packageRoot).toBeNull() + }) + + test('should return null if missing required directories', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + process.chdir(tempDir.name) + + await fs.writeJSON(path.join(tempDir.name, 'package.json'), { + name: 'dpml-prompt' + }) + // Missing prompt directory + + const packageRoot = await discovery._findDevelopmentRoot() + expect(packageRoot).toBeNull() + }) + }) + + describe('Package Root Finding - Installed Environment', () => { + test('should find package root by searching upward', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt') + const searchStartPath = path.join(packagePath, 'src', 'lib', 'core') + + // Create installed package structure + await fs.ensureDir(searchStartPath) + await fs.writeJSON(path.join(packagePath, 'package.json'), { + name: 'dpml-prompt', + version: '1.0.0' + }) + + // Mock _getCurrentDirectory to start search from nested directory + jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue(searchStartPath) + + const packageRoot = await discovery._findInstalledRoot() + expect(packageRoot).toBe(packagePath) + }) + + test('should return null if search finds wrong package', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const packagePath = path.join(tempDir.name, 'node_modules', 'other-package') + const searchStartPath = path.join(packagePath, 'src', 'lib') + + await fs.ensureDir(searchStartPath) + await fs.writeJSON(path.join(packagePath, 'package.json'), { + name: 'other-package', + version: '1.0.0' + }) + + jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue(searchStartPath) + + const packageRoot = await discovery._findInstalledRoot() + expect(packageRoot).toBeNull() + }) + }) + + describe('Package Root Finding - Fallback', () => { + test('should find package using module resolution', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt') + + // Create package structure + await fs.ensureDir(packagePath) + await fs.writeJSON(path.join(packagePath, 'package.json'), { + name: 'dpml-prompt', + version: '1.0.0' + }) + + // Mock resolve to find our package + const resolve = require('resolve') + jest.spyOn(resolve, 'sync').mockReturnValue(path.join(packagePath, 'package.json')) + + const packageRoot = await discovery._findFallbackRoot() + expect(packageRoot).toBe(packagePath) + }) + + test('should return null if module resolution fails', async () => { + const resolve = require('resolve') + jest.spyOn(resolve, 'sync').mockImplementation(() => { + throw new Error('Module not found') + }) + + const packageRoot = await discovery._findFallbackRoot() + expect(packageRoot).toBeNull() + }) + }) + + describe('Registry Path Resolution', () => { + test('should load registry from src/resource.registry.json in development', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const registryPath = path.join(tempDir.name, 'src', 'resource.registry.json') + const testRegistry = { test: 'data' } + + await fs.ensureDir(path.dirname(registryPath)) + await fs.writeJSON(registryPath, testRegistry) + + jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name) + + const registry = await discovery._loadStaticRegistry() + expect(registry).toEqual(testRegistry) + }) + + test('should fallback to alternative registry location', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const altRegistryPath = path.join(tempDir.name, 'resource.registry.json') + const testRegistry = { test: 'alternative' } + + // No src/resource.registry.json, but alternative exists + await fs.writeJSON(altRegistryPath, testRegistry) + + jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name) + + const registry = await discovery._loadStaticRegistry() + expect(registry).toEqual(testRegistry) + }) + + test('should throw error if no registry found', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name) + + await expect(discovery._loadStaticRegistry()).rejects.toThrow('Static registry file not found') + }) + }) + + describe('Integration - Complete Package Discovery Flow', () => { + test('should work end-to-end in development environment', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const projectRoot = tempDir.name + + // Setup complete development environment + await fs.ensureDir(path.join(projectRoot, 'src', 'bin')) + await fs.ensureDir(path.join(projectRoot, 'prompt')) + await fs.writeJSON(path.join(projectRoot, 'package.json'), { + name: 'dpml-prompt' + }) + await fs.writeFile(path.join(projectRoot, 'src', 'bin', 'promptx.js'), '// CLI') + await fs.writeJSON(path.join(projectRoot, 'src', 'resource.registry.json'), { + protocols: { + role: { + registry: { + 'test-role': '@package://test.md' + } + } + } + }) + + process.chdir(projectRoot) + + // Test complete discovery flow + const resources = await discovery.discover() + expect(resources.length).toBeGreaterThan(0) + + // Should find registry resources + const roleResources = resources.filter(r => r.id.startsWith('role:')) + expect(roleResources.length).toBeGreaterThan(0) + }) + + test('should work end-to-end in installed environment', async () => { + const tempDir = tmp.dirSync({ unsafeCleanup: true }) + const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt') + + // Setup installed package structure + await fs.ensureDir(path.join(packagePath, 'src')) + await fs.ensureDir(path.join(packagePath, 'prompt')) + await fs.writeJSON(path.join(packagePath, 'package.json'), { + name: 'dpml-prompt' + }) + await fs.writeJSON(path.join(packagePath, 'src', 'resource.registry.json'), { + protocols: { + role: { + registry: { + 'installed-role': '@package://installed.md' + } + } + } + }) + + // Mock environment detection to return 'local' + jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('local') + jest.spyOn(discovery, '_findInstalledRoot').mockResolvedValue(packagePath) + + const resources = await discovery.discover() + expect(resources.length).toBeGreaterThan(0) + + const roleResources = resources.filter(r => r.id.startsWith('role:')) + expect(roleResources.length).toBeGreaterThan(0) + }) + }) +}) \ No newline at end of file diff --git a/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js b/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js index 6fed569..1a1bbba 100644 --- a/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js +++ b/src/tests/core/resource/discovery/PackageDiscovery.unit.test.js @@ -113,19 +113,38 @@ describe('PackageDiscovery', () => { }) describe('_findPackageRoot', () => { - test('should find package root containing prompt directory', async () => { - // Mock file system check - jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue('/mock/package/root') + test('should find package root in development environment', async () => { + // Mock environment detection and development root finder + jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('development') + jest.spyOn(discovery, '_findDevelopmentRoot').mockResolvedValue('/mock/package/root') const root = await discovery._findPackageRoot() - expect(root).toBe('/mock/package/root') }) - test('should throw error if package root not found', async () => { - jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue(null) + test('should find package root in installed environment', async () => { + // Mock environment detection and installed root finder + jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('local') + jest.spyOn(discovery, '_findInstalledRoot').mockResolvedValue('/mock/node_modules/dpml-prompt') - await expect(discovery._findPackageRoot()).rejects.toThrow('Package root with prompt directory not found') + const root = await discovery._findPackageRoot() + expect(root).toBe('/mock/node_modules/dpml-prompt') + }) + + test('should use fallback method for unknown environment', async () => { + // Mock environment detection and fallback finder + jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('unknown') + jest.spyOn(discovery, '_findFallbackRoot').mockResolvedValue('/mock/fallback/root') + + const root = await discovery._findPackageRoot() + expect(root).toBe('/mock/fallback/root') + }) + + test('should throw error if package root not found', async () => { + jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('development') + jest.spyOn(discovery, '_findDevelopmentRoot').mockResolvedValue(null) + + await expect(discovery._findPackageRoot()).rejects.toThrow('Package root not found') }) }) diff --git a/src/tests/core/resource/resourceManager.integration.test.js b/src/tests/core/resource/resourceManager.integration.test.js index fc4d768..70bd5bb 100644 --- a/src/tests/core/resource/resourceManager.integration.test.js +++ b/src/tests/core/resource/resourceManager.integration.test.js @@ -166,8 +166,9 @@ describe('ResourceManager - Integration Tests', () => { glob.mockResolvedValue([]) await manager.initialize() - await expect(manager.loadResource('non-existent-resource')) - .rejects.toThrow("Resource 'non-existent-resource' not found") + const result = await manager.loadResource('non-existent-resource') + expect(result.success).toBe(false) + expect(result.message).toBe("Resource 'non-existent-resource' not found") }) test('应该处理协议解析失败', async () => { @@ -177,8 +178,9 @@ describe('ResourceManager - Integration Tests', () => { jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed')) - await expect(manager.loadResource('java-backend-developer')) - .rejects.toThrow('Protocol resolution failed') + const result = await manager.loadResource('java-backend-developer') + expect(result.success).toBe(false) + expect(result.message).toBe('Protocol resolution failed') }) test('应该处理文件读取失败', async () => { @@ -194,8 +196,9 @@ describe('ResourceManager - Integration Tests', () => { throw new Error('File not found') }) - await expect(manager.loadResource('java-backend-developer')) - .rejects.toThrow('File not found') + const result = await manager.loadResource('java-backend-developer') + expect(result.success).toBe(false) + expect(result.message).toBe('File not found') }) test('应该处理初始化失败', async () => {