From 5c2493a1f4d4d46c8bf7a69d3791d33fa431bfa1 Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:48:41 -0500 Subject: [PATCH 1/2] Add configurable log level across LSP server, VS, and VS Code clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every logging path was hardcoded to maximum verbosity with no lever to turn it down (issue #16). The server now accepts --log-level, defaulting to Warning, and drives both its file logging and the OmniSharp protocol logging pipeline from the same value. The VS client passes --log-level Warning when spawning the server; VS Code's existing reqnroll.trace.server setting now also maps onto the server's --log-level instead of only gating the client-side wire trace. While adding filtering tests, found that SynchronousFileLogger.Log() overrode the base class's level check and wrote every message unconditionally regardless of the configured Level — fixed, since it would have made the above plumbing a no-op for file logging. Co-Authored-By: Claude Sonnet 5 --- .../Diagnostics/SynchronousFileLogger.cs | 6 +- .../Hosting/ClientIdeContext.cs | 13 ++- .../Hosting/Program.cs | 55 ++++++++++--- .../Hosting/ServiceCollectionExtensions.cs | 6 +- .../Logging/LspDeveroomLogger.cs | 2 +- src/VSCode/package.json | 2 +- src/VSCode/src/extension.ts | 4 +- src/VSCode/src/lspInspectorLogger.ts | 17 ++++ src/VSCode/src/test/extension.test.ts | 1 + .../src/test/lspInspectorLogger.test.ts | 31 ++++++++ .../LspServerConnectionService.cs | 12 ++- .../Diagnostics/SynchronousFileLoggerTests.cs | 79 +++++++++++++++++++ .../Hosting/ClientIdeContextTests.cs | 33 ++++++++ .../Hosting/ProgramTests.cs | 61 ++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 31 ++++++++ .../LspServerConnectionServiceTests.cs | 6 ++ 16 files changed, 338 insertions(+), 21 deletions(-) create mode 100644 src/VSCode/src/test/lspInspectorLogger.test.ts create mode 100644 tests/Core/Reqnroll.IdeSupport.Common.Tests/Diagnostics/SynchronousFileLoggerTests.cs create mode 100644 tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ClientIdeContextTests.cs create mode 100644 tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ProgramTests.cs create mode 100644 tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ServiceCollectionExtensionsTests.cs diff --git a/src/Core/Reqnroll.IdeSupport.Common/Diagnostics/SynchronousFileLogger.cs b/src/Core/Reqnroll.IdeSupport.Common/Diagnostics/SynchronousFileLogger.cs index 32b6009f..34d61dd4 100644 --- a/src/Core/Reqnroll.IdeSupport.Common/Diagnostics/SynchronousFileLogger.cs +++ b/src/Core/Reqnroll.IdeSupport.Common/Diagnostics/SynchronousFileLogger.cs @@ -5,14 +5,16 @@ namespace Reqnroll.IdeSupport.Common.Diagnostics; public class SynchronousFileLogger : AsynchronousFileLogger { - public SynchronousFileLogger(string ide = "vs", string role = "ext") - : base(new FileSystemForIDE(), TraceLevel.Verbose, ide, role) + public SynchronousFileLogger(string ide = "vs", string role = "ext", TraceLevel level = TraceLevel.Warning) + : base(new FileSystemForIDE(), level, ide, role) { EnsureLogFolder(); } public override void Log(LogMessage message) { + if (message.Level > Level) return; + try { WriteLogMessage(message); diff --git a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ClientIdeContext.cs b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ClientIdeContext.cs index b68b1531..82ed95da 100644 --- a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ClientIdeContext.cs +++ b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ClientIdeContext.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; namespace Reqnroll.IdeSupport.LSP.Server.Hosting; @@ -8,11 +9,21 @@ namespace Reqnroll.IdeSupport.LSP.Server.Hosting; /// public sealed class ClientIdeContext { - public ClientIdeContext(string? ide) => Ide = ide; + public ClientIdeContext(string? ide, TraceLevel logLevel = TraceLevel.Warning) + { + Ide = ide; + LogLevel = logLevel; + } /// The raw --ide value, or when absent. public string? Ide { get; } + /// + /// The file/protocol log verbosity requested via --log-level, defaulting to + /// when the client did not specify one. + /// + public TraceLevel LogLevel { get; } + /// /// True when the connecting client is Visual Studio, whose built-in LSP semantic-token /// colorizer cannot map custom token types — so the server pushes tokens to it instead of diff --git a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs index 25284f07..f645c73e 100644 --- a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs +++ b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/Program.cs @@ -1,4 +1,5 @@ -using MediatR; +using System.Diagnostics; +using MediatR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; @@ -34,10 +35,12 @@ public static async Task Main(string[] args) // The semantic token legend no longer varies by IDE, but the identifier is retained for // features that may need to vary their behaviour per IDE (e.g. future static-vs-dynamic // capability registration decisions). - var ideId = args - .SkipWhile(a => !string.Equals(a, "--ide", StringComparison.OrdinalIgnoreCase)) - .Skip(1) - .FirstOrDefault(); + var ideId = ParseArg(args, "--ide"); + + // Each IDE's glue component may pass --log-level (Off/Error/Warning/Info/Verbose) + // when spawning the server. Defaults to Warning when absent so a normal session doesn't + // write maximum-verbosity logs indefinitely; pass --log-level Verbose for full tracing. + var logLevel = ParseLogLevel(args); // Write any unhandled startup exception to a file next to the LSP inspector logs // so crashes are self-diagnosing without needing to capture stderr. @@ -53,7 +56,7 @@ public static async Task Main(string[] args) // Production transport: the IDE talks to the server over stdio. options.WithInput(Console.OpenStandardInput()) .WithOutput(Console.OpenStandardOutput()); - ConfigureServer(options, ideId); + ConfigureServer(options, ideId, logLevel); }); using var preloadCts = new CancellationTokenSource(); @@ -104,11 +107,18 @@ public static async Task Main(string[] args) /// when absent. Currently unused by the semantic-token pipeline /// (the legend is shared across IDEs); retained for features that may vary behaviour per IDE. /// - internal static void ConfigureServer(LanguageServerOptions options, string? clientIde = null) + /// + /// The --log-level verbosity requested by the client, defaulting to + /// . Drives both the file-backed + /// and the OmniSharp protocol-logging minimum level, so wire-level debugging and file logging + /// stay in lockstep unless a caller explicitly asks for more (e.g. --log-level Verbose). + /// + internal static void ConfigureServer(LanguageServerOptions options, string? clientIde = null, + TraceLevel logLevel = TraceLevel.Warning) { options.ConfigureLogging(logging => { - logging.SetMinimumLevel(LogLevel.Trace); + logging.SetMinimumLevel(ToLogLevel(logLevel)); logging.AddLanguageProtocolLogging(); }); @@ -127,7 +137,7 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie // notification to two handler instances (the transient from the scan and // the singleton from the explicit call). .AddMediatR(typeof(Program).Assembly) - .AddReqnrollLspCoreServices(clientIde) + .AddReqnrollLspCoreServices(clientIde, logLevel) .AddReqnrollProjectSystem() .AddReqnrollEditorServices() .AddReqnrollLspHandlers(); @@ -184,4 +194,31 @@ internal static void ConfigureServer(LanguageServerOptions options, string? clie return Task.CompletedTask; }); } + + /// + /// Maps the verbosity scale onto + /// for the OmniSharp protocol-logging pipeline. + /// + internal static LogLevel ToLogLevel(TraceLevel level) => level switch + { + TraceLevel.Off => LogLevel.None, + TraceLevel.Error => LogLevel.Error, + TraceLevel.Warning => LogLevel.Warning, + TraceLevel.Info => LogLevel.Information, + TraceLevel.Verbose => LogLevel.Trace, + _ => LogLevel.Warning + }; + + /// Returns the value following in , or when absent. + internal static string? ParseArg(string[] args, string flag) + => args + .SkipWhile(a => !string.Equals(a, flag, StringComparison.OrdinalIgnoreCase)) + .Skip(1) + .FirstOrDefault(); + + /// Parses --log-level from , defaulting to when absent or unrecognized. + internal static TraceLevel ParseLogLevel(string[] args) + => Enum.TryParse(ParseArg(args, "--log-level"), ignoreCase: true, out var parsedLevel) + ? parsedLevel + : TraceLevel.Warning; } diff --git a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ServiceCollectionExtensions.cs b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ServiceCollectionExtensions.cs index f0990d0f..fed4a430 100644 --- a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ServiceCollectionExtensions.cs +++ b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Hosting/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using MediatR; using Microsoft.Extensions.DependencyInjection; using Reqnroll.IdeSupport.Common; @@ -48,10 +49,11 @@ public static class ServiceCollectionExtensions /// /// Registers core infrastructure and cross-cutting services. /// - public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollection services, string? clientIde) + public static IServiceCollection AddReqnrollLspCoreServices(this IServiceCollection services, string? clientIde, + TraceLevel logLevel = TraceLevel.Warning) { return services - .AddSingleton(new ClientIdeContext(clientIde)) + .AddSingleton(new ClientIdeContext(clientIde, logLevel)) .AddSingleton() .AddSingleton() .AddSingleton(sp => NullMonitoringService.Instance) diff --git a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Logging/LspDeveroomLogger.cs b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Logging/LspDeveroomLogger.cs index 44428ca5..d57ee6b4 100644 --- a/src/LSP/Reqnroll.IdeSupport.LSP.Server/Logging/LspDeveroomLogger.cs +++ b/src/LSP/Reqnroll.IdeSupport.LSP.Server/Logging/LspDeveroomLogger.cs @@ -28,7 +28,7 @@ public LspDeveroomLogger(ClientIdeContext clientIdeContext) }; _inner = new DeveroomCompositeLogger() .Add(new DeveroomDebugLogger()) - .Add(new SynchronousFileLogger(idePrefix, "server")); + .Add(new SynchronousFileLogger(idePrefix, "server", clientIdeContext.LogLevel)); var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; var location = Assembly.GetExecutingAssembly().Location; diff --git a/src/VSCode/package.json b/src/VSCode/package.json index 2e876589..bfc6747d 100644 --- a/src/VSCode/package.json +++ b/src/VSCode/package.json @@ -163,7 +163,7 @@ "type": "string", "enum": ["off", "messages", "verbose"], "default": "off", - "description": "Traces the communication between VS Code and the Reqnroll LSP server. When set to 'verbose', a log file is also written to the Reqnroll log directory." + "description": "Traces the communication between VS Code and the Reqnroll LSP server, and also controls the server's own log verbosity ('off' -> Warning, 'messages' -> Info, 'verbose' -> Verbose). When set to 'verbose', a log file is also written to the Reqnroll log directory. Requires a window reload to take effect on the running server." } } }, diff --git a/src/VSCode/src/extension.ts b/src/VSCode/src/extension.ts index 5f5dd8a1..185d5b0c 100644 --- a/src/VSCode/src/extension.ts +++ b/src/VSCode/src/extension.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; -import { createTraceChannel } from './lspInspectorLogger'; +import { createTraceChannel, traceServerToLogLevel } from './lspInspectorLogger'; import { ProjectManager } from './projectManager'; import { StatusBarManager } from './statusBar'; import { doToggleComment } from './commentToggle'; @@ -197,7 +197,7 @@ export function activate(context: vscode.ExtensionContext): void { // ── LSP client ───────────────────────────────────────────────────────────── const serverOptions: ServerOptions = { command: serverPath, - args: ['--ide', 'vscode'], + args: ['--ide', 'vscode', '--log-level', traceServerToLogLevel()], options: { env: { ...process.env }, }, diff --git a/src/VSCode/src/lspInspectorLogger.ts b/src/VSCode/src/lspInspectorLogger.ts index 6f3ad849..9e5a35fe 100644 --- a/src/VSCode/src/lspInspectorLogger.ts +++ b/src/VSCode/src/lspInspectorLogger.ts @@ -240,6 +240,23 @@ function parseLspTraceMessage(text: string): LspEntry | undefined { * directory so that every JSON-RPC message captured by vscode-languageclient * is persisted in lsp-viewer format alongside the VS Code Output panel entry. */ +/** + * Maps the `reqnroll.trace.server` setting onto the LSP server's `--log-level` argument, so the + * one lever VS Code users already have also controls the server's own file/protocol log + * verbosity instead of only the client-side wire trace. + */ +export function traceServerToLogLevel(): 'Warning' | 'Info' | 'Verbose' { + const level = vscode.workspace.getConfiguration('reqnroll').get('trace.server', 'off'); + switch (level) { + case 'verbose': + return 'Verbose'; + case 'messages': + return 'Info'; + default: + return 'Warning'; + } +} + export function createTraceChannel(): vscode.LogOutputChannel { const level = vscode.workspace.getConfiguration('reqnroll').get('trace.server', 'off'); diff --git a/src/VSCode/src/test/extension.test.ts b/src/VSCode/src/test/extension.test.ts index 2128a05a..89192847 100644 --- a/src/VSCode/src/test/extension.test.ts +++ b/src/VSCode/src/test/extension.test.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; // Pull in all additional test suites so the single entry-point loads them all import './projectManager.test'; +import './lspInspectorLogger.test'; suite('Reqnroll Extension Tests', () => { const extensionId = 'reqnroll.reqnroll-ide-support'; diff --git a/src/VSCode/src/test/lspInspectorLogger.test.ts b/src/VSCode/src/test/lspInspectorLogger.test.ts new file mode 100644 index 00000000..4dd04e70 --- /dev/null +++ b/src/VSCode/src/test/lspInspectorLogger.test.ts @@ -0,0 +1,31 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { traceServerToLogLevel } from '../lspInspectorLogger'; + +suite('traceServerToLogLevel', () => { + const config = vscode.workspace.getConfiguration('reqnroll'); + + teardown(async () => { + await config.update('trace.server', undefined, vscode.ConfigurationTarget.Global); + }); + + test('defaults to Warning when the setting is unset', async () => { + await config.update('trace.server', undefined, vscode.ConfigurationTarget.Global); + assert.strictEqual(traceServerToLogLevel(), 'Warning'); + }); + + test("maps 'off' to Warning", async () => { + await config.update('trace.server', 'off', vscode.ConfigurationTarget.Global); + assert.strictEqual(traceServerToLogLevel(), 'Warning'); + }); + + test("maps 'messages' to Info", async () => { + await config.update('trace.server', 'messages', vscode.ConfigurationTarget.Global); + assert.strictEqual(traceServerToLogLevel(), 'Info'); + }); + + test("maps 'verbose' to Verbose", async () => { + await config.update('trace.server', 'verbose', vscode.ConfigurationTarget.Global); + assert.strictEqual(traceServerToLogLevel(), 'Verbose'); + }); +}); diff --git a/src/VisualStudio/Reqnroll.IdeSupport.VisualStudio.Extension/LspInterception/LspServerConnectionService.cs b/src/VisualStudio/Reqnroll.IdeSupport.VisualStudio.Extension/LspInterception/LspServerConnectionService.cs index da705535..5ea31b83 100644 --- a/src/VisualStudio/Reqnroll.IdeSupport.VisualStudio.Extension/LspInterception/LspServerConnectionService.cs +++ b/src/VisualStudio/Reqnroll.IdeSupport.VisualStudio.Extension/LspInterception/LspServerConnectionService.cs @@ -110,6 +110,14 @@ internal static string ResolveServerExePath(string extensionAssemblyLocation) "LSPServer", "Reqnroll.IdeSupport.LSP.Server.exe"); + /// + /// The command-line arguments passed to the LSP server process: --ide selects the + /// semantic token profile, --log-level keeps the server's own file/protocol logging in + /// step with the client's default rather than the server falling back to its own default + /// independently. Extracted as a constant so it's unit-testable without spawning a process. + /// + internal const string ServerArguments = "--ide visualstudio --log-level Warning"; + private async Task StartAsync() { var serverExe = ResolveServerExePath(typeof(LspServerConnectionService).Assembly.Location); @@ -134,9 +142,7 @@ internal static string ResolveServerExePath(string extensionAssemblyLocation) RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, - // Tell the LSP server which IDE is connecting so it selects the correct - // semantic token profile (legend + DeveroomTag→token type mapping). - Arguments = "--ide visualstudio", + Arguments = ServerArguments, }; _serverProcess = Process.Start(psi) diff --git a/tests/Core/Reqnroll.IdeSupport.Common.Tests/Diagnostics/SynchronousFileLoggerTests.cs b/tests/Core/Reqnroll.IdeSupport.Common.Tests/Diagnostics/SynchronousFileLoggerTests.cs new file mode 100644 index 00000000..af0b0dbf --- /dev/null +++ b/tests/Core/Reqnroll.IdeSupport.Common.Tests/Diagnostics/SynchronousFileLoggerTests.cs @@ -0,0 +1,79 @@ +using System.IO; + +namespace Reqnroll.IdeSupport.Common.Tests.Diagnostics; + +public class SynchronousFileLoggerTests +{ + [Fact] + public void Default_level_is_Warning() + { + var logger = new SynchronousFileLogger("test", $"default-{Guid.NewGuid():N}"); + try + { + logger.Level.Should().Be(TraceLevel.Warning); + } + finally + { + DeleteLogFile(logger); + } + } + + [Theory] + [InlineData(TraceLevel.Off)] + [InlineData(TraceLevel.Error)] + [InlineData(TraceLevel.Warning)] + [InlineData(TraceLevel.Info)] + [InlineData(TraceLevel.Verbose)] + public void Explicit_level_is_honored(TraceLevel level) + { + var logger = new SynchronousFileLogger("test", $"explicit-{Guid.NewGuid():N}", level); + try + { + logger.Level.Should().Be(level); + } + finally + { + DeleteLogFile(logger); + } + } + + [Fact] + public void Messages_above_the_configured_level_are_dropped() + { + var logger = new SynchronousFileLogger("test", $"filter-{Guid.NewGuid():N}", TraceLevel.Warning); + try + { + logger.Log(new LogMessage(TraceLevel.Info, "should be dropped", + nameof(Messages_above_the_configured_level_are_dropped))); + + File.Exists(logger.LogFilePath).Should().BeFalse( + "Info is below the Warning threshold and should never be written"); + } + finally + { + DeleteLogFile(logger); + } + } + + [Fact] + public void Messages_at_or_below_the_configured_level_are_written() + { + var logger = new SynchronousFileLogger("test", $"filter-{Guid.NewGuid():N}", TraceLevel.Warning); + try + { + logger.Log(new LogMessage(TraceLevel.Warning, "should be written", + nameof(Messages_at_or_below_the_configured_level_are_written))); + + File.ReadAllText(logger.LogFilePath).Should().Contain("should be written"); + } + finally + { + DeleteLogFile(logger); + } + } + + private static void DeleteLogFile(SynchronousFileLogger logger) + { + try { File.Delete(logger.LogFilePath); } catch { /* best-effort cleanup */ } + } +} diff --git a/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ClientIdeContextTests.cs b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ClientIdeContextTests.cs new file mode 100644 index 00000000..b6126959 --- /dev/null +++ b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ClientIdeContextTests.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using Reqnroll.IdeSupport.LSP.Server.Hosting; + +namespace Reqnroll.IdeSupport.LSP.Server.Tests.Hosting; + +public class ClientIdeContextTests +{ + [Fact] + public void Default_log_level_is_Warning() + { + new ClientIdeContext("visualstudio").LogLevel.Should().Be(TraceLevel.Warning); + } + + [Theory] + [InlineData(TraceLevel.Off)] + [InlineData(TraceLevel.Error)] + [InlineData(TraceLevel.Warning)] + [InlineData(TraceLevel.Info)] + [InlineData(TraceLevel.Verbose)] + public void Explicit_log_level_is_honored(TraceLevel level) + { + new ClientIdeContext("vscode", level).LogLevel.Should().Be(level); + } + + [Fact] + public void Ide_and_IsVisualStudio_are_unaffected_by_log_level() + { + var context = new ClientIdeContext("visualstudio", TraceLevel.Verbose); + + context.Ide.Should().Be("visualstudio"); + context.IsVisualStudio.Should().BeTrue(); + } +} diff --git a/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ProgramTests.cs b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ProgramTests.cs new file mode 100644 index 00000000..98b6013d --- /dev/null +++ b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ProgramTests.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Reqnroll.IdeSupport.LSP.Server.Hosting; + +namespace Reqnroll.IdeSupport.LSP.Server.Tests.Hosting; + +public class ProgramTests +{ + [Fact] + public void ParseLogLevel_defaults_to_Warning_when_flag_is_absent() + { + Program.ParseLogLevel(new[] { "--ide", "visualstudio" }).Should().Be(TraceLevel.Warning); + } + + [Fact] + public void ParseLogLevel_defaults_to_Warning_for_no_args() + { + Program.ParseLogLevel(Array.Empty()).Should().Be(TraceLevel.Warning); + } + + [Theory] + [InlineData("Off", TraceLevel.Off)] + [InlineData("error", TraceLevel.Error)] + [InlineData("WARNING", TraceLevel.Warning)] + [InlineData("Info", TraceLevel.Info)] + [InlineData("verbose", TraceLevel.Verbose)] + public void ParseLogLevel_parses_case_insensitively(string arg, TraceLevel expected) + { + Program.ParseLogLevel(new[] { "--ide", "vscode", "--log-level", arg }).Should().Be(expected); + } + + [Fact] + public void ParseLogLevel_defaults_to_Warning_for_an_unrecognized_value() + { + Program.ParseLogLevel(new[] { "--log-level", "not-a-level" }).Should().Be(TraceLevel.Warning); + } + + [Fact] + public void ParseArg_returns_the_value_following_the_flag() + { + Program.ParseArg(new[] { "--ide", "visualstudio", "--log-level", "Verbose" }, "--ide") + .Should().Be("visualstudio"); + } + + [Fact] + public void ParseArg_returns_null_when_the_flag_is_absent() + { + Program.ParseArg(new[] { "--ide", "vscode" }, "--log-level").Should().BeNull(); + } + + [Theory] + [InlineData(TraceLevel.Off, LogLevel.None)] + [InlineData(TraceLevel.Error, LogLevel.Error)] + [InlineData(TraceLevel.Warning, LogLevel.Warning)] + [InlineData(TraceLevel.Info, LogLevel.Information)] + [InlineData(TraceLevel.Verbose, LogLevel.Trace)] + public void ToLogLevel_maps_each_TraceLevel_to_the_matching_LogLevel(TraceLevel traceLevel, LogLevel expected) + { + Program.ToLogLevel(traceLevel).Should().Be(expected); + } +} diff --git a/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ServiceCollectionExtensionsTests.cs b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..0a182825 --- /dev/null +++ b/tests/LSP/Reqnroll.IdeSupport.LSP.Server.Tests/Hosting/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Reqnroll.IdeSupport.LSP.Server.Hosting; + +namespace Reqnroll.IdeSupport.LSP.Server.Tests.Hosting; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddReqnrollLspCoreServices_registers_the_requested_log_level_on_ClientIdeContext() + { + var provider = new ServiceCollection() + .AddReqnrollLspCoreServices("vscode", TraceLevel.Verbose) + .BuildServiceProvider(); + + var context = provider.GetRequiredService(); + + context.Ide.Should().Be("vscode"); + context.LogLevel.Should().Be(TraceLevel.Verbose); + } + + [Fact] + public void AddReqnrollLspCoreServices_defaults_the_log_level_to_Warning() + { + var provider = new ServiceCollection() + .AddReqnrollLspCoreServices("visualstudio") + .BuildServiceProvider(); + + provider.GetRequiredService().LogLevel.Should().Be(TraceLevel.Warning); + } +} diff --git a/tests/VisualStudio/Reqnroll.VisualStudio.Tests/LspInterception/LspServerConnectionServiceTests.cs b/tests/VisualStudio/Reqnroll.VisualStudio.Tests/LspInterception/LspServerConnectionServiceTests.cs index 2acfb871..a871be04 100644 --- a/tests/VisualStudio/Reqnroll.VisualStudio.Tests/LspInterception/LspServerConnectionServiceTests.cs +++ b/tests/VisualStudio/Reqnroll.VisualStudio.Tests/LspInterception/LspServerConnectionServiceTests.cs @@ -34,4 +34,10 @@ public void Resolution_is_relative_to_the_assembly_directory_not_the_working_dir path.Should().Be(@"D:\some\other\deep\path\LSPServer\Reqnroll.IdeSupport.LSP.Server.exe"); } + + [Fact] + public void Server_arguments_identify_the_ide_and_a_quiet_default_log_level() + { + LspServerConnectionService.ServerArguments.Should().Be("--ide visualstudio --log-level Warning"); + } } From 10ff03ed20b9a4456a1af1479be25a730b4bcf0e Mon Sep 17 00:00:00 2001 From: Chris Rudolphi <1702962+clrudolphi@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:48:51 -0500 Subject: [PATCH 2/2] Fix VS Code extension test harness never running any tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runTest.ts pointed extensionTestsPath directly at the compiled extension.test.js instead of a Mocha bootstrapper, so the suite/test BDD globals were never registered — every test threw ReferenceError: suite is not defined before running, and npm test failed with exit code 1 without a single test actually executing. Added index.ts to register Mocha and run the bundled tests; this also surfaced a genuine race in "Language client should start after activation" (a 2000ms sleep against Mocha's default 2000ms timeout), fixed by raising the suite timeout. Co-Authored-By: Claude Sonnet 5 --- src/VSCode/src/test/index.ts | 35 ++++++++++++++++++++++++++++++++++ src/VSCode/src/test/runTest.ts | 5 +++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 src/VSCode/src/test/index.ts diff --git a/src/VSCode/src/test/index.ts b/src/VSCode/src/test/index.ts new file mode 100644 index 00000000..872ababc --- /dev/null +++ b/src/VSCode/src/test/index.ts @@ -0,0 +1,35 @@ +import * as path from 'path'; +import Mocha from 'mocha'; + +/** + * Bootstraps the Mocha `suite`/`test` globals inside the Extension Development Host and runs the + * bundled test file. `@vscode/test-electron` loads this module's `run` export as + * `extensionTestsPath` — pointing that directly at a compiled test file (as this used to) never + * registers Mocha's BDD globals, so every suite/test call throws `ReferenceError: suite is not + * defined` before a single test runs. + */ +export function run(): Promise { + // Default Mocha timeout (2000ms) races against extension.test.ts's own 2000ms activation + // wait; give suites headroom now that they actually run (this harness was previously never + // invoking them at all — see remarks above). + const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 10000 }); + const testsRoot = path.resolve(__dirname); + + // extension.test.js pulls in the other suites (projectManager.test, lspInspectorLogger.test) + // via its own imports, so registering just the entry point is enough. + mocha.addFile(path.resolve(testsRoot, 'extension.test.js')); + + return new Promise((resolve, reject) => { + try { + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} test(s) failed.`)); + } else { + resolve(); + } + }); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} diff --git a/src/VSCode/src/test/runTest.ts b/src/VSCode/src/test/runTest.ts index 27966308..b13b5b51 100644 --- a/src/VSCode/src/test/runTest.ts +++ b/src/VSCode/src/test/runTest.ts @@ -7,8 +7,9 @@ async function main(): Promise { // The folder containing the Extension Manifest (package.json) const extensionDevelopmentPath = path.resolve(__dirname, '..', '..'); - // The path to the extension test script - const extensionTestsPath = path.resolve(__dirname, 'extension.test'); + // The path to the test bootstrapper (registers Mocha's suite/test globals, then loads + // extension.test.js which pulls in the other suites via its own imports). + const extensionTestsPath = path.resolve(__dirname, 'index'); // Download VS Code, unzip it and run the integration test await runTests({