diff --git a/jekyll/test_explorer.markdown b/jekyll/test_explorer.markdown index d15d84411..5f8d98087 100644 --- a/jekyll/test_explorer.markdown +++ b/jekyll/test_explorer.markdown @@ -56,6 +56,8 @@ To discover all test files in the workspace with decent performance, the Ruby LS conventions. For a test file to be discovered, the file path must match this glob: `**/{test,spec,features}/**/{*_test.rb,test_*.rb,*_spec.rb,*.feature}` +Tests in certain directories are automatically excluded from discovery: `.bundle`, `vendor/bundle`, `node_modules`, `tmp`, and `log`. + ### Dynamically defined tests There is limited support for tests defined via meta-programming. Initially, they will not be present in the test diff --git a/vscode/src/test/suite/testController.test.ts b/vscode/src/test/suite/testController.test.ts index be904e49a..2410e57d5 100644 --- a/vscode/src/test/suite/testController.test.ts +++ b/vscode/src/test/suite/testController.test.ts @@ -147,23 +147,50 @@ suite("TestController", () => { ); } - function createWorkspaceWithTestFile() { + function createWorkspaceWithTestFile( + options: { + testDir?: string; + testFileName?: string; + testContent?: string; + index?: number; + additionalFiles?: Array<{ path: string; content: string }>; + } = {}, + ) { + const { + testDir = "test", + testFileName = "foo_test.rb", + testContent = "require 'test_helper'\n\nclass FooTest < Minitest::Test; def test_foo; end; end", + index = 1, + additionalFiles = [], + } = options; + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-controller-")); const workspaceUri = vscode.Uri.file(workspacePath); - fs.mkdirSync(path.join(workspaceUri.fsPath, "test")); - const testFilePath = path.join(workspaceUri.fsPath, "test", "foo_test.rb"); - fs.writeFileSync(testFilePath, "require 'test_helper'\n\nclass FooTest < Minitest::Test; def test_foo; end; end"); + fs.mkdirSync(path.join(workspaceUri.fsPath, testDir), { recursive: true }); + const testFilePath = path.join(workspaceUri.fsPath, testDir, testFileName); + fs.writeFileSync(testFilePath, testContent); + + // Create additional files if specified + additionalFiles.forEach(({ path: filePath, content }) => { + const fullPath = path.join(workspaceUri.fsPath, filePath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + }); const workspaceFolder: vscode.WorkspaceFolder = { uri: workspaceUri, name: path.basename(workspacePath), - index: 1, + index, }; return { workspaceFolder, testFileUri: vscode.Uri.file(testFilePath) }; } + function cleanupWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + fs.rmSync(workspaceFolder.uri.fsPath, { recursive: true, force: true }); + } + function stubWorkspaceOperations(...workspaces: vscode.WorkspaceFolder[]) { workspaceStubs.forEach((stub) => stub.restore()); workspaceStubs = []; @@ -927,4 +954,130 @@ suite("TestController", () => { assert.ok(spy.calledOnce); }); + + test("does not discover tests in .bundle directory", async () => { + const { workspaceFolder, testFileUri } = createWorkspaceWithTestFile({ + testFileName: "my_test.rb", + testContent: "class MyTest < Minitest::Test; end", + index: 0, + additionalFiles: [ + { + path: ".bundle/gems/test/gem_test.rb", + content: "class GemTest < Minitest::Test; end", + }, + ], + }); + + stubWorkspaceOperations(workspaceFolder); + + await controller.testController.resolveHandler!(undefined); + + const collection = controller.testController.items; + const testDir = collection.get(vscode.Uri.joinPath(workspaceFolder.uri, "test").toString()); + + // Should find the regular test file + assert.ok(testDir); + const regularTest = testDir.children.get(testFileUri.toString()); + assert.ok(regularTest, "Regular test file should be discovered"); + + // Should NOT find the test file in ignored directory + const ignoredTestDir = collection.get( + vscode.Uri.joinPath(workspaceFolder.uri, ".bundle", "gems", "test").toString(), + ); + assert.strictEqual(ignoredTestDir, undefined, "Ignored directory should not be discovered"); + + cleanupWorkspace(workspaceFolder); + }); + + test("does not discover tests in vendor/bundle directory", async () => { + const { workspaceFolder, testFileUri } = createWorkspaceWithTestFile({ + testDir: "spec", + testFileName: "my_spec.rb", + testContent: "RSpec.describe 'MySpec' do; end", + index: 0, + additionalFiles: [ + { + path: "vendor/bundle/gems/spec/gem_spec.rb", + content: "RSpec.describe 'GemSpec' do; end", + }, + ], + }); + + stubWorkspaceOperations(workspaceFolder); + + await controller.testController.resolveHandler!(undefined); + + const collection = controller.testController.items; + const specDir = collection.get(vscode.Uri.joinPath(workspaceFolder.uri, "spec").toString()); + + // Should find the regular spec file + assert.ok(specDir); + const regularSpec = specDir.children.get(testFileUri.toString()); + assert.ok(regularSpec, "Regular spec file should be discovered"); + + // Should NOT find the test file in ignored directory + const ignoredTestDir = collection.get( + vscode.Uri.joinPath(workspaceFolder.uri, "vendor", "bundle", "gems", "test").toString(), + ); + assert.strictEqual(ignoredTestDir, undefined, "Ignored directory should not be discovered"); + + cleanupWorkspace(workspaceFolder); + }); + + suite("isInIgnoredFolder", () => { + test("detects files in .bundle directory", () => { + const uri = vscode.Uri.file("/workspace/.bundle/gems/ruby/3.3.0/gems/rspec-core-3.12.0/spec/rspec_spec.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("detects files in vendor/bundle directory", () => { + const uri = vscode.Uri.file("/workspace/vendor/bundle/ruby/3.3.0/gems/rspec-core-3.12.0/spec/rspec_spec.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("detects files in nested .bundle paths", () => { + const uri = vscode.Uri.file("/workspace/subfolder/.bundle/ruby/3.3.0/gems/minitest-5.0.0/test/minitest_test.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("detects files in node_modules directory", () => { + const uri = vscode.Uri.file("/workspace/node_modules/@types/node/test/test.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("detects files in tmp directory", () => { + const uri = vscode.Uri.file("/workspace/tmp/cache/test_file.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("detects files in log directory", () => { + const uri = vscode.Uri.file("/workspace/log/test/test_spec.rb"); + assert.ok((controller as any).isInIgnoredFolder(uri)); + }); + + test("does not detect regular test files", () => { + const uri = vscode.Uri.file("/workspace/test/models/user_test.rb"); + assert.strictEqual((controller as any).isInIgnoredFolder(uri), false); + }); + + test("does not detect spec files in regular paths", () => { + const uri = vscode.Uri.file("/workspace/spec/models/user_spec.rb"); + assert.strictEqual((controller as any).isInIgnoredFolder(uri), false); + }); + + test("does not detect files with 'bundle' in name but not in ignored directory", () => { + const uri = vscode.Uri.file("/workspace/test/bundle_test.rb"); + assert.strictEqual((controller as any).isInIgnoredFolder(uri), false); + }); + + test("does not detect files with 'vendor' in name but not in vendor/bundle", () => { + const uri = vscode.Uri.file("/workspace/test/vendor_test.rb"); + assert.strictEqual((controller as any).isInIgnoredFolder(uri), false); + }); + + test("does not detect files in vendor directory that are not in vendor/bundle", () => { + const uri = vscode.Uri.file("/workspace/vendor/plugins/my_plugin/test/plugin_test.rb"); + assert.strictEqual((controller as any).isInIgnoredFolder(uri), false); + }); + }); }); diff --git a/vscode/src/testController.ts b/vscode/src/testController.ts index 4d2c8507d..646249bcb 100644 --- a/vscode/src/testController.ts +++ b/vscode/src/testController.ts @@ -15,6 +15,13 @@ const asyncExec = promisify(exec); const NESTED_TEST_DIR_PATTERN = "**/{test,spec,features}/**/"; const TEST_FILE_PATTERN = `${NESTED_TEST_DIR_PATTERN}{*_test.rb,test_*.rb,*_spec.rb,*.feature}`; +const IGNORED_FOLDERS = [".bundle", "vendor/bundle", "node_modules", "tmp", "log"]; +const IGNORED_FOLDERS_EXCLUDE_PATTERN = `{${IGNORED_FOLDERS.map((folder) => `**/${folder}/**`).join(",")}}`; + +// Build a regex to match paths containing any of the ignored folders +const IGNORED_FOLDERS_PATH_REGEX = new RegExp( + IGNORED_FOLDERS.map((folder) => `/${folder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).join("|"), +); interface CodeLensData { type: string; @@ -142,6 +149,10 @@ export class TestController { testFileWatcher, nestedTestDirWatcher, testFileWatcher.onDidCreate(async (uri) => { + if (this.isInIgnoredFolder(uri)) { + return; + } + const workspace = vscode.workspace.getWorkspaceFolder(uri); if (!workspace || !vscode.workspace.workspaceFolders) { @@ -160,6 +171,10 @@ export class TestController { await this.addTestItemsForFile(uri, workspace, initialCollection); }), testFileWatcher.onDidChange(async (uri) => { + if (this.isInIgnoredFolder(uri)) { + return; + } + const item = await this.getParentTestItem(uri); if (item) { @@ -172,6 +187,10 @@ export class TestController { } }), nestedTestDirWatcher.onDidDelete(async (uri) => { + if (this.isInIgnoredFolder(uri)) { + return; + } + const pathParts = uri.fsPath.split(path.sep); if (pathParts.includes(".git")) { return; @@ -184,6 +203,10 @@ export class TestController { } }), testFileWatcher.onDidDelete(async (uri) => { + if (this.isInIgnoredFolder(uri)) { + return; + } + const item = await this.getParentTestItem(uri); if (item) { @@ -358,7 +381,8 @@ export class TestController { Does the file path match the expected glob pattern? [Read more](https://shopify.github.io/ruby-lsp/test_explorer.html) - Expected pattern: "**/{test,spec,features}/**/{*_test.rb,test_*.rb,*_spec.rb,*.feature}"`, + Expected pattern: "**/{test,spec,features}/**/{*_test.rb,test_*.rb,*_spec.rb,*.feature}" + Excluded paths: ${IGNORED_FOLDERS.map((p) => `"${p}"`).join(", ")}`, ); return; } @@ -981,7 +1005,7 @@ export class TestController { for (const workspaceFolder of workspaceFolders) { // Check if there is at least one Ruby test file in the workspace, otherwise we don't consider it const pattern = this.testPattern(workspaceFolder); - const files = await vscode.workspace.findFiles(pattern, undefined, 1); + const files = await vscode.workspace.findFiles(pattern, IGNORED_FOLDERS_EXCLUDE_PATTERN, 1); if (files.length === 0) { continue; } @@ -1002,7 +1026,7 @@ export class TestController { ) { const initialCollection = item ? item.children : this.testController.items; const pattern = this.testPattern(workspaceFolder); - const filePaths = await vscode.workspace.findFiles(pattern); + const filePaths = await vscode.workspace.findFiles(pattern, IGNORED_FOLDERS_EXCLUDE_PATTERN); const increment = Math.floor(filePaths.length / 100); for (const uri of filePaths) { @@ -1146,6 +1170,11 @@ export class TestController { return false; } + private isInIgnoredFolder(uri: vscode.Uri): boolean { + const normalizedPath = uri.fsPath.split(path.sep).join("/"); + return IGNORED_FOLDERS_PATH_REGEX.test(normalizedPath); + } + private testPattern(workspaceFolder: vscode.WorkspaceFolder) { return new vscode.RelativePattern(workspaceFolder, TEST_FILE_PATTERN); }