diff --git a/index.js b/index.js index aced4a0..c5af993 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,31 @@ const fsSync = require('fs'); const path = require('path'); const crypto = require('crypto'); +/** + * 工具函数:安全执行异步操作,忽略错误 + * + * @template T + * @param {() => Promise} fn - 要执行的异步函数 + * @param {T} defaultValue - 出错时的默认值 + * @returns {Promise} 函数执行结果或默认值 + */ +async function safeExecute(fn, defaultValue = undefined) { + try { + return await fn(); + } catch (error) { + return defaultValue; + } +} + +/** + * 工具函数:安全执行同步操作,忽略错误 + * + * @template T + * @param {() => T} fn - 要执行的同步函数 + * @param {T} defaultValue - 出错时的默认值 + * @returns {T} 函数执行结果或默认值 + */ + /** * 错误类型常量. * @@ -191,8 +216,10 @@ class FileDiscovery { const files = new Set(); for (const pattern of patterns) { - const matchedFiles = await this.expandGlob(pattern.trim()); - matchedFiles.forEach(file => files.add(file)); + await safeExecute(async () => { + const matchedFiles = await this.expandGlob(pattern.trim()); + matchedFiles.forEach(file => files.add(file)); + }); } return Array.from(files).sort(); @@ -240,17 +267,16 @@ class FileDiscovery { async expandGlob(pattern) { // 如果是普通文件路径,直接返回 if (!pattern.includes('*') && !pattern.includes('?')) { - try { - const stats = await fs.stat(pattern); + const stats = await safeExecute(() => fs.stat(pattern)); + if (stats) { if (stats.isFile()) { return [pattern]; } else if (stats.isDirectory()) { // 如果是目录,返回目录下所有文件 return await this.getAllFilesInDirectory(pattern); } - } catch (error) { - return []; } + return []; } // 处理 glob 模式 @@ -267,26 +293,22 @@ class FileDiscovery { async getAllFilesInDirectory(dirPath, includeHidden = false) { const files = []; - try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const entries = await safeExecute(() => fs.readdir(dirPath, { withFileTypes: true }), []); - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); - // 默认忽略隐藏文件(以.开头的文件和目录) - if (!includeHidden && entry.name.startsWith('.')) { - continue; - } - - if (entry.isFile()) { - files.push(fullPath); - } else if (entry.isDirectory()) { - const subFiles = await this.getAllFilesInDirectory(fullPath, includeHidden); - files.push(...subFiles); - } + // 默认忽略隐藏文件(以.开头的文件和目录) + if (!includeHidden && entry.name.startsWith('.')) { + continue; + } + + if (entry.isFile()) { + files.push(fullPath); + } else if (entry.isDirectory()) { + const subFiles = await this.getAllFilesInDirectory(fullPath, includeHidden); + files.push(...subFiles); } - } catch (error) { - // 忽略无法访问的目录 } return files; @@ -312,21 +334,17 @@ class FileDiscovery { } // 处理简单的文件名通配符 - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); + const entries = await safeExecute(() => fs.readdir(dir, { withFileTypes: true }), []); - for (const entry of entries) { - // 默认忽略隐藏文件(以.开头的文件) - if (entry.name.startsWith('.')) { - continue; - } - - if (entry.isFile() && this.matchPattern(entry.name, filename)) { - files.push(path.join(dir, entry.name)); - } + for (const entry of entries) { + // 默认忽略隐藏文件(以.开头的文件) + if (entry.name.startsWith('.')) { + continue; + } + + if (entry.isFile() && this.matchPattern(entry.name, filename)) { + files.push(path.join(dir, entry.name)); } - } catch (error) { - // 目录不存在或无法访问 } return files; @@ -348,17 +366,17 @@ class FileDiscovery { const basePath = parts[0].replace(/\*+$/, '').replace(/\/$/, '') || '.'; const remainingPattern = parts[1].replace(/^\//, ''); - try { - const allFiles = await this.getAllFilesInDirectory(basePath); + const allFiles = await safeExecute(() => this.getAllFilesInDirectory(basePath), []); - for (const file of allFiles) { - const relativePath = path.relative(basePath, file); - if (this.matchPattern(relativePath, remainingPattern)) { - files.push(file); - } + for (const file of allFiles) { + const relativePath = path.relative(basePath, file); + // 对于 **/pattern 模式,匹配相对路径的末尾部分 + if (remainingPattern && (relativePath === remainingPattern || relativePath.endsWith('/' + remainingPattern))) { + files.push(file); + } else if (this.matchPattern(relativePath, remainingPattern)) { + // 对于包含通配符的模式,使用 matchPattern + files.push(file); } - } catch (error) { - // 忽略错误 } } else { // 处理单层通配符 @@ -385,34 +403,30 @@ class FileDiscovery { const [currentPart, ...remainingParts] = parts; const files = []; - try { - const entries = await fs.readdir(currentPath, { withFileTypes: true }); + const entries = await safeExecute(() => fs.readdir(currentPath, { withFileTypes: true }), []); - for (const entry of entries) { - // 默认忽略隐藏文件和目录(以.开头的) - if (entry.name.startsWith('.')) { - continue; - } + for (const entry of entries) { + // 默认忽略隐藏文件和目录(以.开头的) + if (entry.name.startsWith('.')) { + continue; + } - const fullPath = path.join(currentPath, entry.name); + const fullPath = path.join(currentPath, entry.name); - if (this.matchPattern(entry.name, currentPart)) { - if (remainingParts.length === 0) { - // 最后一部分,检查是否为文件 - if (entry.isFile()) { - files.push(fullPath); - } - } else { - // 还有更多部分,递归处理 - if (entry.isDirectory()) { - const subFiles = await this.matchPatternParts(remainingParts, fullPath); - files.push(...subFiles); - } + if (this.matchPattern(entry.name, currentPart)) { + if (remainingParts.length === 0) { + // 最后一部分,检查是否为文件 + if (entry.isFile()) { + files.push(fullPath); + } + } else { + // 还有更多部分,递归处理 + if (entry.isDirectory()) { + const subFiles = await this.matchPatternParts(remainingParts, fullPath); + files.push(...subFiles); } } } - } catch (error) { - // 忽略错误 } return files; @@ -686,4 +700,5 @@ module.exports = { ActionOutputs, ActionError, ErrorType, + safeExecute, }; diff --git a/tests/index.test.js b/tests/index.test.js index e31e1d7..8a4f337 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -76,125 +76,248 @@ describe('测试 FileDiscovery 类', () => { expect(files[0]).toBe('test-dir/file1.js'); }); - it('应该使用通配符模式查找文件', async () => { + it('应该找到通配符(*.js)形式的文件', async () => { const discovery = new FileDiscovery(); const files = await discovery.findFiles(['test-dir/*.js']); expect(files).toHaveLength(1); }); - it('应该递归查找文件', async () => { + it('应该找到递归通配符(**/*.js)形式的文件', async () => { const discovery = new FileDiscovery(); const files = await discovery.findFiles(['test-dir/**/*.js']); expect(files).toHaveLength(2); }); - it('应该处理空目录', async () => { + it('应该找到递归通配符开头与文件名(**/config.json)形式的文件', async () => { const tempDir = await createTestDir(); const discovery = new FileDiscovery(); - const files = await discovery.findFiles([tempDir]); + // 创建多个目录层级的同名文件 + const configFiles = [ + path.join(tempDir, 'config.json'), + path.join(tempDir, 'src', 'config.json'), + path.join(tempDir, 'src', 'utils', 'config.json'), + path.join(tempDir, 'lib', 'config.json'), + path.join(tempDir, 'test', 'config.json'), + ]; - expect(files).toEqual([]); - cleanupTestDirs(tempDir); - }); + // 创建一些其他文件用于验证不会误匹配 + const otherFiles = [ + path.join(tempDir, 'src', 'app.js'), + path.join(tempDir, 'src', 'utils', 'helper.js'), + path.join(tempDir, 'lib', 'main.js'), + path.join(tempDir, 'test', 'test.js'), + ]; - it('应该处理无效模式', async () => { - const discovery = new FileDiscovery(); - const files = await discovery.findFiles(['nonexistent/**/*.txt']); - - expect(files).toEqual([]); - }); - - it('应该处理混合有效和无效模式', async () => { - const tempDir = await createTestDir(); - const testFile = path.join(tempDir, 'test.txt'); - await createTestFile(testFile, 'content'); - const discovery = new FileDiscovery(); - - const files = await discovery.findFiles([path.join(tempDir, '*.txt'), 'nonexistent/**/*.txt']); - - expect(files).toContain(testFile); - cleanupTestFiles([testFile]); - cleanupTestDirs(tempDir); - }); - - it('应该处理无权限的目录', async () => { - const tempDir = await createTestDir(); - const restrictedDir = path.join(tempDir, 'restricted'); - const discovery = new FileDiscovery(); - - try { - fs.mkdirSync(restrictedDir, { mode: 0o000 }); - - const files = await discovery.findFiles([restrictedDir + '/*.txt']); - - expect(files).toEqual([]); - } catch (error) { - // 某些系统可能不允许创建无权限目录,这是预期的 - expect(error.code).toBe('EACCES'); - } finally { - // 清理:先恢复权限再删除 - try { - fs.chmodSync(restrictedDir, 0o755); - cleanupTestDirs(tempDir); - } catch (cleanupError) { - // 忽略清理错误 - } + // 创建所有文件 + for (const file of [...configFiles, ...otherFiles]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + await createTestFile(file, '{"test": "content"}'); } - }); - it('应该忽略隐藏文件(默认)', async () => { - const tempDir = await createTestDir(); - const hiddenFile = path.join(tempDir, '.hidden.txt'); - const visibleFile = path.join(tempDir, 'visible.txt'); - const discovery = new FileDiscovery(); + // 测试 tempDir 目录下的 **/config.json 模式(限制在测试目录内) + const files = await discovery.findFiles([path.join(tempDir, '**/config.json')]); - await createTestFile(hiddenFile, 'hidden content'); - await createTestFile(visibleFile, 'visible content'); + // 应该找到所有的 config.json 文件 + expect(files).toHaveLength(configFiles.length); + for (const configFile of configFiles) { + expect(files).toContain(configFile); + } - const files = await discovery.findFiles([path.join(tempDir, '*.txt')]); - - expect(files).toContain(visibleFile); - expect(files).not.toContain(hiddenFile); - - cleanupTestFiles(hiddenFile, visibleFile); + // 清理测试文件 + cleanupTestFiles([...configFiles, ...otherFiles]); cleanupTestDirs(tempDir); }); - it('应该处理空文件', async () => { + it('应该找到递归通配符开头与扩展名(**/*.md)形式的文件', async () => { const tempDir = await createTestDir(); - const emptyFile = path.join(tempDir, 'empty.txt'); const discovery = new FileDiscovery(); - await createTestFile(emptyFile, ''); + // 创建多层目录结构中的 .md 文件 + const mdFiles = [ + path.join(tempDir, 'README.md'), + path.join(tempDir, 'docs', 'guide.md'), + path.join(tempDir, 'src', 'docs', 'api.md'), + path.join(tempDir, 'lib', 'components', 'button.md'), + path.join(tempDir, 'test', 'integration', 'tests.md'), + ]; - const files = await discovery.findFiles([path.join(tempDir, '*.txt')]); + // 创建一些其他扩展名的文件用于验证不会误匹配 + const otherFiles = [ + path.join(tempDir, 'src', 'main.js'), + path.join(tempDir, 'src', 'styles.css'), + path.join(tempDir, 'docs', 'config.json'), + path.join(tempDir, 'test', 'test.py'), + ]; - expect(files).toContain(emptyFile); + // 创建所有文件 + for (const file of [...mdFiles, ...otherFiles]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + await createTestFile(file, '# Test Content'); + } - cleanupTestFiles(emptyFile); + // 测试 tempDir 目录下的 **/*.md 模式(限制在测试目录内) + const files = await discovery.findFiles([path.join(tempDir, '**/*.md')]); + + // 应该找到所有的 .md 文件 + expect(files).toHaveLength(mdFiles.length); + for (const mdFile of mdFiles) { + expect(files).toContain(mdFile); + } + + // 清理测试文件 + cleanupTestFiles([...mdFiles, ...otherFiles]); cleanupTestDirs(tempDir); }); - it('应该处理嵌套很深的目录', async () => { + it('应该正确处理递归通配符前缀的复杂模式', async () => { const tempDir = await createTestDir(); - const deepFile = path.join(tempDir, 'level1', 'level2', 'level3', 'deep.txt'); const discovery = new FileDiscovery(); - fs.mkdirSync(path.dirname(deepFile), { recursive: true }); - await createTestFile(deepFile, 'deep content'); + // 创建测试文件结构 + const testFiles = [ + path.join(tempDir, 'src', 'components', 'Button.js'), + path.join(tempDir, 'src', 'components', 'Input.js'), + path.join(tempDir, 'src', 'utils', 'helpers.js'), + path.join(tempDir, 'lib', 'vendor', 'jquery.js'), + path.join(tempDir, 'test', 'unit', 'Button.test.js'), + path.join(tempDir, 'test', 'integration', 'App.test.js'), + ]; - const files = await discovery.findFiles([path.join(tempDir, '**/*.txt')]); + // 创建一些非 JS 文件 + const nonJSFiles = [ + path.join(tempDir, 'src', 'styles.css'), + path.join(tempDir, 'docs', 'README.md'), + path.join(tempDir, 'config.json'), + ]; - expect(files).toContain(deepFile); + // 创建所有文件 + for (const file of [...testFiles, ...nonJSFiles]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + await createTestFile(file, 'console.log("test");'); + } - cleanupTestFiles(deepFile); + // 测试 tempDir 目录下的 **/*.js 模式(限制在测试目录内) + const files = await discovery.findFiles([path.join(tempDir, '**/*.js')]); + + // 应该找到所有的 .js 文件 + expect(files).toHaveLength(testFiles.length); + for (const jsFile of testFiles) { + expect(files).toContain(jsFile); + } + + // 清理测试文件 + cleanupTestFiles([...testFiles, ...nonJSFiles]); cleanupTestDirs(tempDir); }); }); + it('应该处理空目录', async () => { + const tempDir = await createTestDir(); + const discovery = new FileDiscovery(); + + const files = await discovery.findFiles([tempDir]); + + expect(files).toEqual([]); + cleanupTestDirs(tempDir); + }); + + it('应该处理无效模式', async () => { + const discovery = new FileDiscovery(); + const files = await discovery.findFiles(['nonexistent/**/*.txt']); + + expect(files).toEqual([]); + }); + + it('应该处理混合有效和无效模式', async () => { + const tempDir = await createTestDir(); + const testFile = path.join(tempDir, 'test.txt'); + await createTestFile(testFile, 'content'); + const discovery = new FileDiscovery(); + + const files = await discovery.findFiles([path.join(tempDir, '*.txt'), 'nonexistent/**/*.txt']); + + expect(files).toContain(testFile); + cleanupTestFiles([testFile]); + cleanupTestDirs(tempDir); + }); + + it('应该处理无权限的目录', async () => { + const tempDir = await createTestDir(); + const restrictedDir = path.join(tempDir, 'restricted'); + const discovery = new FileDiscovery(); + + try { + fs.mkdirSync(restrictedDir, { mode: 0o000 }); + + const files = await discovery.findFiles([restrictedDir + '/*.txt']); + + expect(files).toEqual([]); + } catch (error) { + // 某些系统可能不允许创建无权限目录,这是预期的 + expect(error.code).toBe('EACCES'); + } finally { + // 清理:先恢复权限再删除 + try { + fs.chmodSync(restrictedDir, 0o755); + cleanupTestDirs(tempDir); + } catch (cleanupError) { + // 忽略清理错误 + } + } + }); + + it('应该忽略隐藏文件(默认)', async () => { + const tempDir = await createTestDir(); + const hiddenFile = path.join(tempDir, '.hidden.txt'); + const visibleFile = path.join(tempDir, 'visible.txt'); + const discovery = new FileDiscovery(); + + await createTestFile(hiddenFile, 'hidden content'); + await createTestFile(visibleFile, 'visible content'); + + const files = await discovery.findFiles([path.join(tempDir, '*.txt')]); + + expect(files).toContain(visibleFile); + expect(files).not.toContain(hiddenFile); + + cleanupTestFiles(hiddenFile, visibleFile); + cleanupTestDirs(tempDir); + }); + + it('应该处理空文件', async () => { + const tempDir = await createTestDir(); + const emptyFile = path.join(tempDir, 'empty.txt'); + const discovery = new FileDiscovery(); + + await createTestFile(emptyFile, ''); + + const files = await discovery.findFiles([path.join(tempDir, '*.txt')]); + + expect(files).toContain(emptyFile); + + cleanupTestFiles(emptyFile); + cleanupTestDirs(tempDir); + }); + + it('应该处理嵌套很深的目录', async () => { + const tempDir = await createTestDir(); + const deepFile = path.join(tempDir, 'level1', 'level2', 'level3', 'deep.txt'); + const discovery = new FileDiscovery(); + + fs.mkdirSync(path.dirname(deepFile), { recursive: true }); + await createTestFile(deepFile, 'deep content'); + + const files = await discovery.findFiles([path.join(tempDir, '**/*.txt')]); + + expect(files).toContain(deepFile); + + cleanupTestFiles(deepFile); + cleanupTestDirs(tempDir); + }); + describe('测试 FileDiscovery.validateFiles 方法', () => { const existingFile = 'existing-file.txt'; const nonExistingFile = 'non-existing-file.txt';