From b0f99fcf13456a99a174eb232465878026f68106 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 31 Jan 2025 11:46:27 -0500 Subject: [PATCH] Start discovering all available tests lazily --- vscode/package.json | 4 + vscode/src/common.ts | 1 + vscode/src/test/suite/testController.test.ts | 151 ++++++++++++++++- vscode/src/testController.ts | 169 ++++++++++++++++++- 4 files changed, 319 insertions(+), 6 deletions(-) diff --git a/vscode/package.json b/vscode/package.json index 77d35464e..0e2e8341d 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -497,6 +497,10 @@ "description": "Opt-in/out of the Tapioca add-on", "type": "boolean" }, + "newTestExperience": { + "description": "UNDER DEVEOPMENT. Opt-in/out of the new test experience", + "type": "boolean" + }, "launcher": { "description": "Opt-in/out of the new launcher mode", "type": "boolean" diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 3841d6b66..71245dd8a 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -85,6 +85,7 @@ export const SUPPORTED_LANGUAGE_IDS = ["ruby", "erb"]; export const FEATURE_FLAGS = { tapiocaAddon: 0.3, launcher: 0.05, + newTestExperience: -1, }; type FeatureFlagConfigurationKey = keyof typeof FEATURE_FLAGS | "all"; diff --git a/vscode/src/test/suite/testController.test.ts b/vscode/src/test/suite/testController.test.ts index 29b198768..4c72b67cd 100644 --- a/vscode/src/test/suite/testController.test.ts +++ b/vscode/src/test/suite/testController.test.ts @@ -1,14 +1,28 @@ import * as assert from "assert"; +import path from "path"; +import fs from "fs"; +import os from "os"; import * as vscode from "vscode"; import { CodeLens } from "vscode-languageclient/node"; +import { afterEach } from "mocha"; +import sinon from "sinon"; import { TestController } from "../../testController"; -import { Command } from "../../common"; +import * as common from "../../common"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; suite("TestController", () => { + const workspacePath = path.dirname( + path.dirname(path.dirname(path.dirname(__dirname))), + ); + const workspaceUri = vscode.Uri.file(workspacePath); + const workspaceFolder: vscode.WorkspaceFolder = { + uri: workspaceUri, + name: path.basename(workspaceUri.fsPath), + index: 0, + }; const context = { extensionMode: vscode.ExtensionMode.Test, subscriptions: [], @@ -18,6 +32,10 @@ suite("TestController", () => { }, } as unknown as vscode.ExtensionContext; + afterEach(() => { + context.subscriptions.forEach((subscription) => subscription.dispose()); + }); + test("createTestItems doesn't break when there's a missing group", () => { const controller = new TestController( context, @@ -30,7 +48,7 @@ suite("TestController", () => { range: new vscode.Range(0, 0, 10, 10), command: { title: "Run", - command: Command.RunTest, + command: common.Command.RunTest, arguments: [ "test/fake_test.rb", "test_do_something", @@ -58,4 +76,133 @@ suite("TestController", () => { controller.createTestItems(codeLensItems); }); }); + + test("populates test structure directly if there's only one workspace", async () => { + const stub = sinon.stub(common, "featureEnabled").returns(true); + const controller = new TestController( + context, + FAKE_TELEMETRY, + () => undefined, + ); + stub.restore(); + + const workspacesStub = sinon + .stub(vscode.workspace, "workspaceFolders") + .get(() => [workspaceFolder]); + + const relativePathStub = sinon + .stub(vscode.workspace, "asRelativePath") + .callsFake((uri) => + path.relative(workspacePath, (uri as vscode.Uri).fsPath), + ); + + await controller.testController.resolveHandler!(undefined); + workspacesStub.restore(); + relativePathStub.restore(); + + const collection = controller.testController.items; + + const testDir = collection.get( + vscode.Uri.joinPath(workspaceUri, "test").toString(), + ); + assert.ok(testDir); + + const serverTest = testDir!.children.get( + vscode.Uri.joinPath(workspaceUri, "test", "server_test.rb").toString(), + ); + assert.ok(serverTest); + }); + + test("makes the workspaces the top level when there's more than one", async () => { + const stub = sinon.stub(common, "featureEnabled").returns(true); + const controller = new TestController( + context, + FAKE_TELEMETRY, + () => undefined, + ); + stub.restore(); + + const secondWorkspacePath = fs.mkdtempSync( + path.join(os.tmpdir(), "ruby-lsp-test-controller-"), + ); + const secondWorkspaceUri = vscode.Uri.file(secondWorkspacePath); + + fs.mkdirSync(path.join(secondWorkspaceUri.fsPath, "test")); + fs.writeFileSync( + path.join(secondWorkspaceUri.fsPath, "test", "other_test.rb"), + "require 'test_helper'\n\nclass OtherTest < Minitest::Test; end", + ); + + const secondWorkspaceFolder: vscode.WorkspaceFolder = { + uri: secondWorkspaceUri, + name: "second_workspace", + index: 1, + }; + const workspacesStub = sinon + .stub(vscode.workspace, "workspaceFolders") + .get(() => [workspaceFolder, secondWorkspaceFolder]); + + const relativePathStub = sinon + .stub(vscode.workspace, "asRelativePath") + .callsFake((uri) => { + const filePath = (uri as vscode.Uri).fsPath; + + if (path.basename(filePath) === "other_test.rb") { + return path.relative(secondWorkspacePath, filePath); + } else { + return path.relative(workspacePath, filePath); + } + }); + + const getWorkspaceStub = sinon + .stub(vscode.workspace, "getWorkspaceFolder") + .callsFake((uri) => { + if (uri === secondWorkspaceUri) { + return secondWorkspaceFolder; + } else { + return workspaceFolder; + } + }); + + await controller.testController.resolveHandler!(undefined); + + const collection = controller.testController.items; + + // First workspace + const workspaceItem = collection.get(workspaceUri.toString()); + assert.ok(workspaceItem); + await controller.testController.resolveHandler!(workspaceItem); + + const testDir = workspaceItem!.children.get( + vscode.Uri.joinPath(workspaceUri, "test").toString(), + ); + assert.ok(testDir); + + const serverTest = testDir!.children.get( + vscode.Uri.joinPath(workspaceUri, "test", "server_test.rb").toString(), + ); + assert.ok(serverTest); + + // Second workspace + const secondWorkspaceItem = collection.get(secondWorkspaceUri.toString()); + assert.ok(secondWorkspaceItem); + await controller.testController.resolveHandler!(secondWorkspaceItem); + + const secondTestDir = secondWorkspaceItem!.children.get( + vscode.Uri.joinPath(secondWorkspaceUri, "test").toString(), + ); + assert.ok(secondTestDir); + + const otherTest = secondTestDir!.children.get( + vscode.Uri.joinPath( + secondWorkspaceUri, + "test", + "other_test.rb", + ).toString(), + ); + assert.ok(otherTest); + workspacesStub.restore(); + relativePathStub.restore(); + getWorkspaceStub.restore(); + }); }); diff --git a/vscode/src/testController.ts b/vscode/src/testController.ts index d4c7112ea..34fbc6dd9 100644 --- a/vscode/src/testController.ts +++ b/vscode/src/testController.ts @@ -1,10 +1,12 @@ import { exec } from "child_process"; import { promisify } from "util"; +import path from "path"; import * as vscode from "vscode"; import { CodeLens } from "vscode-languageclient/node"; import { Workspace } from "./workspace"; +import { featureEnabled } from "./common"; const asyncExec = promisify(exec); @@ -16,12 +18,16 @@ interface CodeLensData { kind: string; } +const WORKSPACE_TAG = new vscode.TestTag("workspace"); +const TEST_DIR_TAG = new vscode.TestTag("test_dir"); +const DEBUG_TAG = new vscode.TestTag("debug"); + export class TestController { - private readonly testController: vscode.TestController; + // Only public for testing + readonly testController: vscode.TestController; private readonly testCommands: WeakMap; private readonly testRunProfile: vscode.TestRunProfile; private readonly testDebugProfile: vscode.TestRunProfile; - private readonly debugTag: vscode.TestTag = new vscode.TestTag("debug"); private terminal: vscode.Terminal | undefined; private readonly telemetry: vscode.TelemetryLogger; // We allow the timeout to be configured in seconds, but exec expects it in milliseconds @@ -30,6 +36,7 @@ export class TestController { .get("testTimeout") as number; private readonly currentWorkspace: () => Workspace | undefined; + private readonly newExperience = featureEnabled("newTestExperience"); constructor( context: vscode.ExtensionContext, @@ -43,6 +50,10 @@ export class TestController { "Ruby Tests", ); + if (this.newExperience) { + this.testController.resolveHandler = this.resolveHandler.bind(this); + } + this.testCommands = new WeakMap(); this.testRunProfile = this.testController.createRunProfile( @@ -61,7 +72,7 @@ export class TestController { await this.debugHandler(request, token); }, false, - this.debugTag, + DEBUG_TAG, ); context.subscriptions.push( @@ -75,6 +86,11 @@ export class TestController { } createTestItems(response: CodeLens[]) { + // In the new experience, we will no longer overload code lens + if (this.newExperience) { + return; + } + this.testController.items.forEach((test) => { this.testController.items.delete(test.id); this.testCommands.delete(test); @@ -113,7 +129,7 @@ export class TestController { testItem.canResolveChildren = true; } else { // Set example tags - testItem.tags = [...testItem.tags, this.debugTag]; + testItem.tags = [...testItem.tags, DEBUG_TAG]; } // Examples always have a `group_id`. Groups may or may not have it @@ -400,4 +416,149 @@ export class TestController { return testItem; } + + private async resolveHandler( + item: vscode.TestItem | undefined, + ): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return; + } + + if (item) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(item.uri!)!; + + // If the item is a workspace, then we need to gather all test files inside of it + if (item.tags.some((tag) => tag === WORKSPACE_TAG)) { + await this.gatherWorkspaceTests(workspaceFolder, item); + } else { + // TODO: we are resolving the children of a specific test file. Here we will ask the server to parse the file, + // run all available listeners and then populate all available groups and examples + } + } else if (workspaceFolders.length === 1) { + // If there's only one workspace, there's no point in nesting the tests under the workspace name + await this.gatherWorkspaceTests(workspaceFolders[0], undefined); + } else { + // If there's more than one workspace, we use them as the top level items + 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); + if (files.length === 0) { + continue; + } + + const uri = workspaceFolder.uri; + const testItem = this.testController.createTestItem( + uri.toString(), + workspaceFolder.name, + uri, + ); + testItem.canResolveChildren = true; + testItem.tags = [WORKSPACE_TAG, DEBUG_TAG]; + this.testController.items.add(testItem); + } + } + } + + private async gatherWorkspaceTests( + workspaceFolder: vscode.WorkspaceFolder, + item: vscode.TestItem | undefined, + ) { + const initialCollection = item ? item.children : this.testController.items; + const pattern = this.testPattern(workspaceFolder); + + for (const uri of await vscode.workspace.findFiles(pattern)) { + const fileName = path.basename(uri.fsPath); + + if (fileName === "test_helper.rb") { + continue; + } + + // Find the position of the `test/spec/feature` directory. There may be many in applications that are divided by + // components, so we want to show each individual test directory as a separate item + const relativePath = vscode.workspace.asRelativePath(uri); + const pathParts = relativePath.split(path.sep); + const dirPosition = this.testDirectoryPosition(pathParts); + const firstLevelName = pathParts.slice(0, dirPosition + 1).join(path.sep); + const firstLevelUri = vscode.Uri.joinPath( + workspaceFolder.uri, + firstLevelName, + ); + + let firstLevel = initialCollection.get(firstLevelUri.toString()); + if (!firstLevel) { + firstLevel = this.testController.createTestItem( + firstLevelUri.toString(), + firstLevelName, + firstLevelUri, + ); + firstLevel.tags = [TEST_DIR_TAG, DEBUG_TAG]; + initialCollection.add(firstLevel); + } + + // In Rails apps, it's also very common to divide the test directory into a second hierarchy level, like models or + // controllers. Here we try to find out if there is a second level, allowing users to run all tests for models for + // example + const secondLevelName = pathParts + .slice(dirPosition + 1, dirPosition + 2) + .join(path.sep); + const secondLevelUri = vscode.Uri.joinPath( + firstLevelUri, + secondLevelName, + ); + + const fileStat = await vscode.workspace.fs.stat(secondLevelUri); + let finalCollection = firstLevel.children; + + // We only consider something to be another level of hierarchy if it's a directory + if (fileStat.type === vscode.FileType.Directory) { + let secondLevel = firstLevel.children.get(secondLevelUri.toString()); + + if (!secondLevel) { + secondLevel = this.testController.createTestItem( + secondLevelUri.toString(), + secondLevelName, + secondLevelUri, + ); + secondLevel.tags = [TEST_DIR_TAG, DEBUG_TAG]; + firstLevel.children.add(secondLevel); + } + + finalCollection = secondLevel.children; + } + + // Finally, add the test file to whatever is the final collection, which may be the first level test directory or + // a second level like models + const testItem = this.testController.createTestItem( + uri.toString(), + fileName, + uri, + ); + testItem.canResolveChildren = true; + testItem.tags = [DEBUG_TAG]; + finalCollection.add(testItem); + } + } + + private testPattern(workspaceFolder: vscode.WorkspaceFolder) { + return new vscode.RelativePattern( + workspaceFolder, + "**/{test,spec,features}/**/{*_test.rb,test_*.rb,*_spec.rb,*.feature}", + ); + } + + private testDirectoryPosition(pathParts: string[]) { + let index = pathParts.indexOf("test"); + if (index !== -1) { + return index; + } + + index = pathParts.indexOf("spec"); + if (index !== -1) { + return index; + } + + return pathParts.indexOf("features"); + } }