From 724f0363c8c6f2602440d5e4efe793c39635e2dc Mon Sep 17 00:00:00 2001 From: Nithin Bekal Date: Fri, 30 May 2025 15:05:53 -0400 Subject: [PATCH 1/3] Add profile code lens --- .../response_builders/test_collection.rb | 5 + lib/ruby_lsp/server.rb | 3 + vscode/src/common.ts | 1 + vscode/src/rubyLsp.ts | 8 + vscode/src/streamingRunner.ts | 1 + vscode/src/testController.ts | 146 +++++++++++++++++- 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/lib/ruby_lsp/response_builders/test_collection.rb b/lib/ruby_lsp/response_builders/test_collection.rb index 70f9a3957a..75138236d6 100644 --- a/lib/ruby_lsp/response_builders/test_collection.rb +++ b/lib/ruby_lsp/response_builders/test_collection.rb @@ -43,6 +43,11 @@ def add_code_lens(item) range: range, data: { arguments: arguments, kind: "debug_test" }, ) + + @code_lens << Interface::CodeLens.new( + range: range, + data: { arguments: arguments, kind: "profile_test" }, + ) end #: (String id) -> ResponseType? diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 6f1838713c..c95e55e392 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -1520,6 +1520,9 @@ def code_lens_resolve(message) Interface::Command.new(title: "▶ Run in terminal", command: "rubyLsp.runTestInTerminal", arguments: args) when "debug_test" code_lens[:command] = Interface::Command.new(title: "⚙ Debug", command: "rubyLsp.debugTest", arguments: args) + when "profile_test" + code_lens[:command] = + Interface::Command.new(title: "⏱ Profile", command: "rubyLsp.profileTest", arguments: args) end send_message(Result.new( diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 0ed9f4bd36..c2a331ecdd 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -19,6 +19,7 @@ export enum Command { RunTest = "rubyLsp.runTest", RunTestInTerminal = "rubyLsp.runTestInTerminal", DebugTest = "rubyLsp.debugTest", + ProfileTest = "rubyLsp.profileTest", ShowSyntaxTree = "rubyLsp.showSyntaxTree", DiagnoseState = "rubyLsp.diagnoseState", DisplayAddons = "rubyLsp.displayAddons", diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index ecfd99d51b..10ca072b2c 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -477,6 +477,14 @@ export class RubyLsp { : this.testController.debugTest(path, name, command); }, ), + vscode.commands.registerCommand( + Command.ProfileTest, + (path, name, command) => { + return featureEnabled("fullTestDiscovery") + ? this.testController.runViaCommand(path, name, Mode.Profile) + : this.testController.profileTest(path, name, command); + }, + ), vscode.commands.registerCommand( Command.RunTask, async (command: string) => { diff --git a/vscode/src/streamingRunner.ts b/vscode/src/streamingRunner.ts index 67cb2f5309..4b891f7733 100644 --- a/vscode/src/streamingRunner.ts +++ b/vscode/src/streamingRunner.ts @@ -29,6 +29,7 @@ export enum Mode { Run = "run", RunInTerminal = "runInTerminal", Debug = "debug", + Profile = "profile", } // The StreamingRunner class is responsible for executing the test process or launching the debugger while handling the diff --git a/vscode/src/testController.ts b/vscode/src/testController.ts index b4b119013c..d422806d64 100644 --- a/vscode/src/testController.ts +++ b/vscode/src/testController.ts @@ -34,6 +34,7 @@ const RUN_PROFILE_LABEL = "Run"; const RUN_IN_TERMINAL_PROFILE_LABEL = "Run in terminal"; const DEBUG_PROFILE_LABEL = "Debug"; const COVERAGE_PROFILE_LABEL = "Coverage"; +const PROFILE_PROFILE_LABEL = "Profile"; export class TestController { // Only public for testing @@ -42,6 +43,7 @@ export class TestController { readonly runInTerminalProfile: vscode.TestRunProfile; readonly coverageProfile: vscode.TestRunProfile; readonly testDebugProfile: vscode.TestRunProfile; + readonly profileProfile: vscode.TestRunProfile; private readonly testCommands: WeakMap; private terminal: vscode.Terminal | undefined; private readonly telemetry: vscode.TelemetryLogger; @@ -143,6 +145,13 @@ export class TestController { false, ); + this.profileProfile = this.testController.createRunProfile( + PROFILE_PROFILE_LABEL, + vscode.TestRunProfileKind.Run, + this.runTest.bind(this), + false, + ); + const testFileWatcher = vscode.workspace.createFileSystemWatcher(TEST_FILE_PATTERN); @@ -160,6 +169,7 @@ export class TestController { this.coverageProfile, this.runner, this.runInTerminalProfile, + this.profileProfile, vscode.window.onDidCloseTerminal((terminal: vscode.Terminal): void => { if (terminal === this.terminal) this.terminal = undefined; }), @@ -357,6 +367,42 @@ export class TestController { }); } + /** + * @deprecated by {@link runViaCommand}. To be removed once the new test explorer is fully rolled out + */ + profileTest( + _path: string, + _id: string, + command?: string, + _location?: any, + _name?: string, + ) { + // The command is passed as the third argument from the code lens + // If it's not provided, fall back to finding the test by active line + // eslint-disable-next-line no-param-reassign + command ??= this.testCommands.get(this.findTestByActiveLine()!) || ""; + + if (!command) { + vscode.window.showErrorMessage("No test command found to profile"); + return; + } + + if (this.terminal === undefined) { + this.terminal = this.getTerminal(); + } + + this.terminal.show(); + this.terminal.sendText(`vernier run --format cpuprofile -- ${command}`); + + this.telemetry.logUsage("ruby_lsp.code_lens", { + type: "counter", + attributes: { + label: "profile_test", + vscodemachineid: vscode.env.machineId, + }, + }); + } + // Public for testing purposes. Receives the controller's inclusions and exclusions and builds request test items for // the server to resolve the command buildRequestTestItems( @@ -401,6 +447,77 @@ export class TestController { let profile; + // For Profile mode + if (mode === Mode.Profile) { + if (this.terminal === undefined) { + this.terminal = this.getTerminal(); + } + + let commandToExecute: string | undefined; + + if (this.fullDiscovery) { + const workspaceFolder = vscode.workspace.getWorkspaceFolder( + testItem.uri!, + ); + if (!workspaceFolder) { + vscode.window.showErrorMessage( + "Could not find workspace for the test item.", + ); + return; + } + const workspace = await this.getOrActivateWorkspace(workspaceFolder); + + if ( + workspace.lspClient && + workspace.lspClient.initializeResult?.capabilities.experimental + ?.full_test_discovery + ) { + const requestTestItems = [this.testItemToServerItem(testItem)]; + try { + const response = + await workspace.lspClient.resolveTestCommands(requestTestItems); + + if (response && response.commands && response.commands.length > 0) { + commandToExecute = response.commands[0]; + } else { + vscode.window.showErrorMessage( + "LSP server did not return a command for profiling.", + ); + return; + } + } catch (error: any) { + vscode.window.showErrorMessage( + `Error resolving test command for profiling: ${error.message}`, + ); + this.currentWorkspace()?.outputChannel.error( + `Error resolving test command for profiling: ${error.message}`, + ); + return; + } + } else { + vscode.window.showErrorMessage( + "Cannot profile test: Ruby LSP server does not support necessary test discovery " + + "features or is not ready. Please update the Ruby LSP gem or check server status.", + ); + return; + } + } else { + // Old discovery path + commandToExecute = this.testCommands.get(testItem); + } + + if (!commandToExecute) { + vscode.window.showErrorMessage("No test command found to profile."); + return; + } + + this.terminal.show(); + this.terminal.sendText( + `vernier run --format cpuprofile -- ${commandToExecute}`, + ); + return; + } + switch (mode) { case Mode.Debug: profile = this.testDebugProfile; @@ -636,6 +753,14 @@ export class TestController { run.end(); linkedCancellationSource.dispose(); + + this.telemetry.logUsage("ruby_lsp.code_lens", { + type: "counter", + attributes: { + label: "test", + vscodemachineid: vscode.env.machineId, + }, + }); } // When trying to a test file or directory, we need to know which framework is used by tests inside of it to resolve @@ -760,6 +885,17 @@ export class TestController { linkedCancellationSource, ); } + + run.end(); + linkedCancellationSource.dispose(); + + this.telemetry.logUsage("ruby_lsp.code_lens", { + type: "counter", + attributes: { + label: "debug", + vscodemachineid: vscode.env.machineId, + }, + }); } private findTestInGroup( @@ -854,7 +990,10 @@ export class TestController { this.telemetry.logUsage("ruby_lsp.code_lens", { type: "counter", - attributes: { label: "debug", vscodemachineid: vscode.env.machineId }, + attributes: { + label: "debug", + vscodemachineid: vscode.env.machineId, + }, }); } @@ -952,7 +1091,10 @@ export class TestController { this.telemetry.logUsage("ruby_lsp.code_lens", { type: "counter", - attributes: { label: "test", vscodemachineid: vscode.env.machineId }, + attributes: { + label: "test", + vscodemachineid: vscode.env.machineId, + }, }); } From 4d2b83969ac79f96d6275e971bfa531e35956e3a Mon Sep 17 00:00:00 2001 From: Jean-Samuel Aubry-Guzzi Date: Fri, 30 May 2025 15:39:44 -0400 Subject: [PATCH 2/3] Open window with profile --- vscode/src/rubyLsp.ts | 36 ++++++++-------- vscode/src/testController.ts | 80 ++++++++++++++---------------------- 2 files changed, 48 insertions(+), 68 deletions(-) diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index 10ca072b2c..1db5c32e8d 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -479,10 +479,8 @@ export class RubyLsp { ), vscode.commands.registerCommand( Command.ProfileTest, - (path, name, command) => { - return featureEnabled("fullTestDiscovery") - ? this.testController.runViaCommand(path, name, Mode.Profile) - : this.testController.profileTest(path, name, command); + (path, name, _command) => { + this.testController.runViaCommand(path, name, Mode.Profile) }, ), vscode.commands.registerCommand( @@ -608,14 +606,14 @@ export class RubyLsp { command: string; args: any[]; } & vscode.QuickPickItem)[] = [ - { - label: "Minitest test", - description: "Create a new Minitest test", - iconPath: new vscode.ThemeIcon("new-file"), - command: Command.NewMinitestFile, - args: [], - }, - ]; + { + label: "Minitest test", + description: "Create a new Minitest test", + iconPath: new vscode.ThemeIcon("new-file"), + command: Command.NewMinitestFile, + args: [], + }, + ]; if ( workspace.lspClient?.addons?.some( @@ -840,15 +838,15 @@ export class RubyLsp { const response: | { - workerAlive: boolean; - backtrace: string[]; - documents: { uri: string; source: string }; - incomingQueueSize: number; - } + workerAlive: boolean; + backtrace: string[]; + documents: { uri: string; source: string }; + incomingQueueSize: number; + } | null | undefined = await workspace?.lspClient?.sendRequest( - "rubyLsp/diagnoseState", - ); + "rubyLsp/diagnoseState", + ); if (response) { const documentData = Object.entries(response.documents); diff --git a/vscode/src/testController.ts b/vscode/src/testController.ts index d422806d64..e7d0a107c8 100644 --- a/vscode/src/testController.ts +++ b/vscode/src/testController.ts @@ -1,5 +1,6 @@ import { exec } from "child_process"; import { promisify } from "util"; +import * as os from "os"; import path from "path"; import * as vscode from "vscode"; @@ -118,12 +119,12 @@ export class TestController { this.fullDiscovery ? this.runTest.bind(this) : async () => { - await vscode.window.showInformationMessage( - `Running tests with coverage requires the new explorer implementation, + await vscode.window.showInformationMessage( + `Running tests with coverage requires the new explorer implementation, which is currently under development. If you wish to enable it, set the "fullTestDiscovery" feature flag to "true"`, - ); - }, + ); + }, false, ); @@ -367,42 +368,6 @@ export class TestController { }); } - /** - * @deprecated by {@link runViaCommand}. To be removed once the new test explorer is fully rolled out - */ - profileTest( - _path: string, - _id: string, - command?: string, - _location?: any, - _name?: string, - ) { - // The command is passed as the third argument from the code lens - // If it's not provided, fall back to finding the test by active line - // eslint-disable-next-line no-param-reassign - command ??= this.testCommands.get(this.findTestByActiveLine()!) || ""; - - if (!command) { - vscode.window.showErrorMessage("No test command found to profile"); - return; - } - - if (this.terminal === undefined) { - this.terminal = this.getTerminal(); - } - - this.terminal.show(); - this.terminal.sendText(`vernier run --format cpuprofile -- ${command}`); - - this.telemetry.logUsage("ruby_lsp.code_lens", { - type: "counter", - attributes: { - label: "profile_test", - vscodemachineid: vscode.env.machineId, - }, - }); - } - // Public for testing purposes. Receives the controller's inclusions and exclusions and builds request test items for // the server to resolve the command buildRequestTestItems( @@ -427,8 +392,8 @@ export class TestController { } // Method to run tests in any profile through code lens buttons - async runViaCommand(path: string, name: string, mode: Mode) { - const uri = vscode.Uri.file(path); + async runViaCommand(filePath: string, name: string, mode: Mode) { + const uri = vscode.Uri.file(filePath); const testItem = await this.findTestItem(name, uri); if (!testItem) return; @@ -454,6 +419,7 @@ export class TestController { } let commandToExecute: string | undefined; + let workspace: Workspace | undefined; if (this.fullDiscovery) { const workspaceFolder = vscode.workspace.getWorkspaceFolder( @@ -465,7 +431,7 @@ export class TestController { ); return; } - const workspace = await this.getOrActivateWorkspace(workspaceFolder); + workspace = await this.getOrActivateWorkspace(workspaceFolder); if ( workspace.lspClient && @@ -497,7 +463,7 @@ export class TestController { } else { vscode.window.showErrorMessage( "Cannot profile test: Ruby LSP server does not support necessary test discovery " + - "features or is not ready. Please update the Ruby LSP gem or check server status.", + "features or is not ready. Please update the Ruby LSP gem or check server status.", ); return; } @@ -511,9 +477,25 @@ export class TestController { return; } - this.terminal.show(); - this.terminal.sendText( - `vernier run --format cpuprofile -- ${commandToExecute}`, + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Profiling in progress...", + cancellable: false, + }, + async () => { + const profileUri = vscode.Uri.file( + path.join(os.tmpdir(), `profile-${Date.now()}.cpuprofile`), + ); + + await workspace!.execute( + `vernier run --output ${profileUri.fsPath} --format cpuprofile -- ${commandToExecute}`, + ); + + await vscode.commands.executeCommand("vscode.open", profileUri, { + viewColumn: vscode.ViewColumn.Beside, + }); + }, ); return; } @@ -972,8 +954,8 @@ export class TestController { return previousTerminal ? previousTerminal : vscode.window.createTerminal({ - name, - }); + name, + }); } private async debugHandler( From 2e0073eee3e8ad0f0dc10f92d6de76d5fdbc528c Mon Sep 17 00:00:00 2001 From: Jean-Samuel Aubry-Guzzi Date: Mon, 2 Jun 2025 15:08:48 -0400 Subject: [PATCH 3/3] Remove auto formatting --- .../response_builders/test_collection.rb | 5 ---- vscode/package.json | 5 ++++ vscode/src/rubyLsp.ts | 30 +++++++++---------- vscode/src/testController.ts | 11 ------- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/lib/ruby_lsp/response_builders/test_collection.rb b/lib/ruby_lsp/response_builders/test_collection.rb index 75138236d6..70f9a3957a 100644 --- a/lib/ruby_lsp/response_builders/test_collection.rb +++ b/lib/ruby_lsp/response_builders/test_collection.rb @@ -43,11 +43,6 @@ def add_code_lens(item) range: range, data: { arguments: arguments, kind: "debug_test" }, ) - - @code_lens << Interface::CodeLens.new( - range: range, - data: { arguments: arguments, kind: "profile_test" }, - ) end #: (String id) -> ResponseType? diff --git a/vscode/package.json b/vscode/package.json index e783c90653..74ef715bf0 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -191,6 +191,11 @@ "command": "rubyLsp.showOutput", "title": "Show output channel", "category": "Ruby LSP" + }, + { + "command": "rubyLsp.profileTest", + "title": "Profile current test file", + "category": "Ruby LSP" } ], "configuration": { diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index 1db5c32e8d..042351dc07 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -606,14 +606,14 @@ export class RubyLsp { command: string; args: any[]; } & vscode.QuickPickItem)[] = [ - { - label: "Minitest test", - description: "Create a new Minitest test", - iconPath: new vscode.ThemeIcon("new-file"), - command: Command.NewMinitestFile, - args: [], - }, - ]; + { + label: "Minitest test", + description: "Create a new Minitest test", + iconPath: new vscode.ThemeIcon("new-file"), + command: Command.NewMinitestFile, + args: [], + }, + ]; if ( workspace.lspClient?.addons?.some( @@ -838,15 +838,15 @@ export class RubyLsp { const response: | { - workerAlive: boolean; - backtrace: string[]; - documents: { uri: string; source: string }; - incomingQueueSize: number; - } + workerAlive: boolean; + backtrace: string[]; + documents: { uri: string; source: string }; + incomingQueueSize: number; + } | null | undefined = await workspace?.lspClient?.sendRequest( - "rubyLsp/diagnoseState", - ); + "rubyLsp/diagnoseState", + ); if (response) { const documentData = Object.entries(response.documents); diff --git a/vscode/src/testController.ts b/vscode/src/testController.ts index e7d0a107c8..07c7c7130d 100644 --- a/vscode/src/testController.ts +++ b/vscode/src/testController.ts @@ -35,7 +35,6 @@ const RUN_PROFILE_LABEL = "Run"; const RUN_IN_TERMINAL_PROFILE_LABEL = "Run in terminal"; const DEBUG_PROFILE_LABEL = "Debug"; const COVERAGE_PROFILE_LABEL = "Coverage"; -const PROFILE_PROFILE_LABEL = "Profile"; export class TestController { // Only public for testing @@ -44,7 +43,6 @@ export class TestController { readonly runInTerminalProfile: vscode.TestRunProfile; readonly coverageProfile: vscode.TestRunProfile; readonly testDebugProfile: vscode.TestRunProfile; - readonly profileProfile: vscode.TestRunProfile; private readonly testCommands: WeakMap; private terminal: vscode.Terminal | undefined; private readonly telemetry: vscode.TelemetryLogger; @@ -146,13 +144,6 @@ export class TestController { false, ); - this.profileProfile = this.testController.createRunProfile( - PROFILE_PROFILE_LABEL, - vscode.TestRunProfileKind.Run, - this.runTest.bind(this), - false, - ); - const testFileWatcher = vscode.workspace.createFileSystemWatcher(TEST_FILE_PATTERN); @@ -170,7 +161,6 @@ export class TestController { this.coverageProfile, this.runner, this.runInTerminalProfile, - this.profileProfile, vscode.window.onDidCloseTerminal((terminal: vscode.Terminal): void => { if (terminal === this.terminal) this.terminal = undefined; }), @@ -412,7 +402,6 @@ export class TestController { let profile; - // For Profile mode if (mode === Mode.Profile) { if (this.terminal === undefined) { this.terminal = this.getTerminal();