diff --git a/AGENTS.md b/AGENTS.md index 3284f6e7..aaf1f612 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ The test runner is **Microsoft.Testing.Platform** with the **xUnit v3** runner ( ### Test-suite behavior to know about -- Heavy Roslyn/integration tests share `[Collection(nameof(RoslynServices))]` and run **serially** on purpose: `MSBuildLocator.RegisterDefaults()` and the loader's `AssemblyLoadContext.Resolving` hooks (attached to the process-global Default ALC by `AssemblyLoadContextHook` and never detached) are process-global, not per-`RoslynServices`. The full suite is ~2 minutes. +- Heavy Roslyn/integration tests share `[Collection(nameof(RoslynServices))]` and run **serially** on purpose: the loader's `AssemblyLoadContext.Resolving` hooks (attached to the process-global Default ALC by `AssemblyLoadContextHook` and never detached) are process-global, not per-`RoslynServices`. The full suite is ~2 minutes. (`MSBuildLocator.RegisterDefaults()` is also process-global, but a `[ModuleInitializer]` in `TestAssemblyInitializer` runs it once at assembly load, so it's no longer the reason for the collection — and tests that only needed MSBuildLocator, like `NugetPackageInstallerTests`, can now run in isolation.) - Some tests spawn `dotnet build` / MSBuild subprocesses (solution/project references) and a few touch the network (NuGet install). These are the slow ones and can occasionally be flaky. - The inspect-feature integration tests (`InspectorRoundTripTests`, `InspectorCancellationTests`, `RemoteEditorServicesTests`, `InspectorServerProtocolTests`, `RemoteReadEvalPrintLoopTests`) **launch a real hooked child process** — the interactive PrettyPrompt loop itself cannot be driven without a TTY, so `RemoteReadEvalPrintLoopTests` stubs `IPrompt` (like `ReadEvalPrintLoopTests`) and everything below it is real. Two more inspect suites run in-process without a child: `InspectorEngineTests` hosts the real engine + Roslyn inside the test process, and `InspectorTransportTests` exercises the real OS pipe/socket transport (note: the Windows pipe uses zero-byte buffers, so a write rendezvouses with the peer's read — keep the read pending while writing). diff --git a/CSharpRepl.Services/IConsoleService.cs b/CSharpRepl.Services/IConsoleService.cs index 637279bd..fc8a3e95 100644 --- a/CSharpRepl.Services/IConsoleService.cs +++ b/CSharpRepl.Services/IConsoleService.cs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +using System; +using System.Threading.Tasks; using PrettyPrompt.Consoles; using PrettyPrompt.Highlighting; using Spectre.Console; @@ -20,6 +22,13 @@ public interface IConsoleService // The underlying Spectre console that provides e.g. color coded / wrapped output. protected IAnsiConsole Ansi { get; } + /// + /// Whether the console is an interactive terminal. False when output is redirected (piped, --eval, + /// captured by a tool), where cursor movement and live displays (e.g. status spinners) can't render and + /// must degrade to plain text. Mirrors !Console.IsOutputRedirected; overridable so it can be faked in tests. + /// + bool IsInteractive => !Console.IsOutputRedirected; + /// Width, in characters, of the console buffer — for layout/wrapping math. int BufferWidth => PrettyPromptConsole.BufferWidth; @@ -36,6 +45,17 @@ public interface IConsoleService void Write(string text) => Ansi.Write(text); void Write(FormattedString text) => PrettyPromptConsole.Write(text); + /// Writes a line of Spectre.Console markup. During a live display (e.g. a status spinner) it renders above the live region. + void WriteMarkupLine(string markup) => Ansi.MarkupLine(markup); + + /// + /// Runs while displaying an animated status spinner labelled + /// (Spectre markup), returning the action's result. Output written to this console during the action - e.g. via + /// - is rendered above the live spinner and remains after it disappears. + /// + Task RunWithStatusAsync(string status, Spinner spinner, string color, Func> action) + => Ansi.Status().Spinner(spinner).SpinnerStyle(Style.Parse(color)).StartAsync(status, _ => action()); + void WriteLine(string text) => Ansi.WriteLine(text); void WriteLine() => Ansi.WriteLine(); void WriteLine(FormattedString text) => PrettyPromptConsole.WriteLine(text); diff --git a/CSharpRepl.Services/Nuget/ConsoleNugetLogger.cs b/CSharpRepl.Services/Nuget/ConsoleNugetLogger.cs index bbb831a3..5558526a 100644 --- a/CSharpRepl.Services/Nuget/ConsoleNugetLogger.cs +++ b/CSharpRepl.Services/Nuget/ConsoleNugetLogger.cs @@ -1,48 +1,67 @@ -// This Source Code Form is subject to the terms of the Mozilla Public +// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. using System; -using System.Collections.Generic; -using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Classification; using NuGet.Common; -using PrettyPrompt.Consoles; -using PrettyPrompt.Highlighting; +using Spectre.Console; namespace CSharpRepl.Services.Nuget; /// -/// Implementation of that logs minimal output to the console. +/// Implementation of that renders NuGet restore output in the REPL. +/// When the console is interactive, a restore runs under an animated Spectre.Console status spinner with +/// log lines rendered above it. +/// +/// When stdout is redirected (piped, --eval, captured by a tool) we fall back to plain, line-by-line text. /// -internal sealed class ConsoleNugetLogger : ILogger +internal sealed partial class ConsoleNugetLogger : ILogger { - private const int NumberOfMessagesToShow = 6; + // Segments worth highlighting within a line: 'quoted' text (group 1, usually a package id) and URLs + // (group 2). Everything else is rendered in the line's body color. + private static readonly Regex HighlightRegex = HighlightRegexGenerator(); + + private const string LinkStyle = "blue"; private readonly IConsoleService console; - private readonly Configuration configuration; - private readonly string successPrefix; + private readonly bool useUnicode; private readonly string errorPrefix; - private readonly List lines = []; - private readonly object linesLock = new(); // Lock for the lines list - private int linesRendered; - // The normal 'pretty' rendering uses cursor movement and the console buffer width, which only - // work against a real terminal. When stdout is redirected (e.g. piped, --eval, or captured by a - // tool) those operations throw "The handle is invalid", so if we're non-interactive we should - // just write plain text. - private readonly bool interactive; + // The theme's string-literal color, as Spectre markup (e.g. "yellow"), used to highlight 'quoted' + // package ids; empty when the theme defines no such color. + private readonly string quoteStyle; + + private readonly bool interactive; // are we running in an interactive terminal, or is stdout redirected to a file/pipe? public ConsoleNugetLogger(IConsoleService console, Configuration configuration) { this.console = console; - this.configuration = configuration; - this.interactive = !Console.IsOutputRedirected; + this.interactive = console.IsInteractive; + this.useUnicode = configuration.UseUnicode; + + errorPrefix = useUnicode ? "❌ " : ""; + quoteStyle = configuration.Theme.GetSyntaxHighlightingSpectreColor(ClassificationTypeNames.StringLiteral)?.ToMarkup() ?? ""; + } + + /// + /// Runs under an animated status spinner labelled with the package being + /// installed. Falls back to running the operation as-is (with plain, line-by-line output) when the + /// console is non-interactive. + /// + public Task WithStatusAsync(string packageId, Func> operation) + { + if (!interactive) + { + return operation(); + } - successPrefix = configuration.UseUnicode ? "✅ " : ""; - errorPrefix = configuration.UseUnicode ? "❌ " : ""; + var name = quoteStyle.Length > 0 ? $"[{quoteStyle}]{Markup.Escape(packageId)}[/]" : Markup.Escape(packageId); + var spinner = useUnicode ? Spinner.Known.Dots : Spinner.Known.Ascii; + return console.RunWithStatusAsync($"Installing NuGet package {name}", spinner, "green", operation); } public void Log(ILogMessage message) => Log(message.Level, message.Message); @@ -51,208 +70,138 @@ public void Log(LogLevel level, string data) { switch (level) { - case LogLevel.Debug: LogDebug(data); return; - case LogLevel.Verbose: LogVerbose(data); return; - case LogLevel.Information: LogInformation(data); return; - case LogLevel.Minimal: LogMinimal(data); return; - case LogLevel.Warning: LogWarning(data); return; - case LogLevel.Error: LogError(data); return; + case LogLevel.Information: + case LogLevel.Minimal: LogProgress(data); return; + case LogLevel.Warning: LogIssue(data, isError: false); return; + case LogLevel.Error: LogIssue(data, isError: true); return; + // Debug/Verbose are far too chatty for the REPL. default: return; } } - public void LogMinimal(string data) - { - var line = CreateLine(data, isError: false); - if (line.IsEmpty) return; + public void LogDebug(string data) { } + public void LogVerbose(string data) { } + public void LogInformation(string data) => LogProgress(data); + public void LogMinimal(string data) => LogProgress(data); + public void LogInformationSummary(string data) => LogProgress(data); + public void LogWarning(string data) => LogIssue(data, isError: false); + public void LogError(string data) => LogIssue(data, isError: true); - if (!interactive) - { - NonInteractiveAppendLine(line); - return; - } - - lock (linesLock) - { - lines.Add(line); - if (lines.Count(l => !l.IsError) > NumberOfMessagesToShow) - { - for (int i = 0; i < lines.Count; i++) - { - if (!lines[i].IsError) - { - lines.RemoveAt(i); - break; - } - } - } - } - - RenderLines(); + public Task LogAsync(LogLevel level, string data) + { + Log(level, data); + return Task.CompletedTask; } - public void LogWarning(string data) => LogMinimal(data); - public void LogInformationSummary(string data) => LogMinimal(data); + public Task LogAsync(ILogMessage message) + { + Log(message); + return Task.CompletedTask; + } - public void LogError(string data) + /// Writes the final outcome line of a restore. Called after the spinner has stopped. + public void LogFinish(string text, bool success) { - var line = CreateLine(data, isError: true); + var data = Truncate(text); + if (data.Length == 0) return; + var prefix = success ? "" : errorPrefix; if (!interactive) { - NonInteractiveAppendLine(line); + console.WriteLine(); + console.WriteStandardOutputLine(prefix + data); return; } - lock (linesLock) - { - lines.Add(line); - } - RenderLines(); + console.WriteMarkupLine(Markup.Escape(prefix) + ToMarkup(data, success ? "green" : "red")); } - public void Reset() + // Information/Minimal/InformationSummary: progress detail, written as a persistent log line. While the + // spinner is live Spectre renders these lines above it (they stay in the scrollback after it disappears). + private void LogProgress(string data) { - lock (linesLock) + var text = Truncate(data); + if (text.Length == 0) return; + + if (interactive) { - lines.Clear(); + console.WriteMarkupLine(ToMarkup(text, "white")); + } + else + { + console.WriteStandardOutputLine(text); } - linesRendered = 0; } - public void LogFinish(string text, bool success) + // Warnings/errors are worth keeping in the scrollback, so they're written as persistent lines even + // while the spinner is live (Spectre renders them above it). + private void LogIssue(string data, bool isError) { + var text = Truncate(data); + if (text.Length == 0) return; + if (!interactive) { - var summary = CreateLine(text, isError: !success); - if (!summary.IsEmpty) - { - NonInteractiveAppendLine(summary); - } - + console.WriteStandardOutputLine((isError ? errorPrefix : "") + text); return; } - //delete rendered lines - for (int i = 0; i < linesRendered; i++) - { - console.Write(AnsiEscapeCodes.GetMoveCursorUp(1)); - console.Write(AnsiEscapeCodes.ClearLine); - } - linesRendered = 0; - - lock (linesLock) - { - //keep only errors - lines.RemoveAll(line => !line.IsError); - //add final summary - lines.Add(CreateLine(text, isError: !success)); - } - - //render summary + potential errors - RenderLines(); + var prefix = isError ? errorPrefix : ""; + console.WriteMarkupLine(Markup.Escape(prefix) + ToMarkup(text, isError ? "red" : "yellow")); } - // unused - public Task LogAsync(LogLevel level, string data) => Task.CompletedTask; - public Task LogAsync(ILogMessage message) => Task.CompletedTask; - public void LogDebug(string data) { /* ignore, we don't need this much output */ } - public void LogVerbose(string data) { /* ignore, we don't need this much output */ } - public void LogInformation(string data) { /* ignore, we don't need this much output */ } - - private Line CreateLine(string data, bool isError) => new(data, isError, isError ? errorPrefix : successPrefix, configuration); + private string ToMarkup(string text, string bodyStyle) => Highlight(text, bodyStyle, quoteStyle); /// - /// Write the message as plain text. No cursor movement and no ANSI color. + /// Builds a Spectre markup string from raw NuGet text: escapes markup metacharacters (so a version + /// range like "[1.0,2.0)" isn't parsed as markup) and highlights 'quoted' segments - usually package + /// ids - with (the theme's string-literal color), and URLs in blue. + /// Everything else is rendered in . /// - private void NonInteractiveAppendLine(Line line) => console.WriteStandardOutputLine(line.Text.Text ?? ""); - - private void RenderLines() + internal static string Highlight(string text, string bodyStyle, string quoteStyle) { - try + var sb = new StringBuilder(); + int pos = 0; + foreach (Match match in HighlightRegex.Matches(text)) { - console.Cursor.Show(false); - for (int i = 0; i < linesRendered; i++) - { - console.Write(AnsiEscapeCodes.GetMoveCursorUp(1)); - console.Write(AnsiEscapeCodes.ClearLine); - } - - lock (linesLock) - { - linesRendered = 0; - foreach (var line in lines) - { - if (line.IsError) - { - console.Write(AnsiColor.Red.GetEscapeSequence()); - console.WriteLine(line.Text.Text ?? ""); - console.Write(AnsiEscapeCodes.Reset); - } - else - { - console.WriteLine(line.Text); - } - - linesRendered += Math.DivRem(line.Text.Length, console.BufferWidth, out var remainder) + (remainder == 0 ? 0 : 1); - } - } + Append(text[pos..match.Index], bodyStyle); + var isUrl = match.Groups[2].Success; + var style = isUrl ? LinkStyle : (quoteStyle.Length > 0 ? quoteStyle : bodyStyle); + Append(match.Value, style); + pos = match.Index + match.Length; } - finally + Append(text[pos..], bodyStyle); + return sb.ToString(); + + void Append(string part, string style) { - console.Cursor.Show(true); + if (part.Length == 0) return; + sb.Append('[').Append(style).Append(']').Append(Markup.Escape(part)).Append("[/]"); } } - private readonly struct Line + /// + /// NuGet output can be a bit overwhelming. Truncate some of the longer lines. + /// + private static string Truncate(string data) { - private static readonly Regex QuotesRegex = new(@"'.*?'"); + if (string.IsNullOrWhiteSpace(data)) return ""; - public readonly FormattedString Text; - public readonly bool IsError; - public readonly bool IsEmpty; + if (data.StartsWith("Successfully installed", StringComparison.Ordinal)) + return ""; // ignore; NugetPackageInstaller will log success on its own - public Line(string data, bool isError, string prefix, Configuration configuration) - { - data = Truncate(data); - IsEmpty = data.Length == 0; - Text = IsEmpty ? prefix : Format(data, prefix, configuration); - IsError = isError; - } - - /// - /// Nuget output can be a bit overwhelming. Truncate some of the longer lines - /// - private static string Truncate(string data) - { - if (string.IsNullOrWhiteSpace(data)) return ""; + if (data.IndexOf(" to folder", StringComparison.Ordinal) is int discard1 and >= 0) + return data[..discard1]; - if (data.IndexOf(" to folder") is int discard1 and >= 0) - return data.Substring(0, discard1); + if (data.IndexOf(" with respect to project", StringComparison.Ordinal) is int discard2 and >= 0) + return data[..discard2]; - if (data.IndexOf(" with respect to project") is int discard2 and >= 0) - return data.Substring(0, discard2); + if (data.IndexOf(" with content hash ", StringComparison.Ordinal) is int discard3 and >= 0) + return data[..discard3]; - if (data.StartsWith("Successfully installed")) - return ""; //ignore; NugetPackageInstaller will log success on its own - - return data; - } - - private static FormattedString Format(string text, string prefix, Configuration configuration) - { - text = prefix + text; - if (configuration.Theme.TryGetSyntaxHighlightingAnsiColor(ClassificationTypeNames.StringLiteral, out var color)) - { - var formattings = new List(1); - foreach (Match match in QuotesRegex.Matches(text)) - { - formattings.Add(new FormatSpan(match.Index, match.Length, color)); - } - return new FormattedString(text, formattings.ToArray()); - } - - return text; - } + return data; } -} \ No newline at end of file + + [GeneratedRegex(@"('.*?')|(https?://\S+)", RegexOptions.Compiled)] + private static partial Regex HighlightRegexGenerator(); +} diff --git a/CSharpRepl.Services/Nuget/NugetPackageInstaller.cs b/CSharpRepl.Services/Nuget/NugetPackageInstaller.cs index 9dc3b1d9..bd802ed9 100644 --- a/CSharpRepl.Services/Nuget/NugetPackageInstaller.cs +++ b/CSharpRepl.Services/Nuget/NugetPackageInstaller.cs @@ -70,14 +70,15 @@ public async Task> InstallAsync( string? version = null, CancellationToken cancellationToken = default) { - logger.Reset(); - await restoreLock.WaitAsync(cancellationToken).ConfigureAwait(false); var hadPrevious = topLevelPackages.TryGetValue(packageId, out var previousRange); try { topLevelPackages[packageId] = ParseRequestedRange(version); - var outcome = await RestoreAsync(packageId, cancellationToken).ConfigureAwait(false); + + var outcome = await logger + .WithStatusAsync(packageId, () => RestoreAsync(packageId, cancellationToken)) + .ConfigureAwait(false); if (outcome.Succeeded) { // The package may legitimately contribute no references of its own (e.g. it's provided by the @@ -134,6 +135,8 @@ private async Task RestoreAsync(string requestedPackageId, Cance return RestoreOutcome.Failed; } + logger.LogInformation(""); + logger.LogInformation(Environment.NewLine + $"Installing package '{requestedPackageId}'"); var runtimeIdentifier = RuntimeInformation.RuntimeIdentifier; var settings = ReadSettings(); var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(settings); diff --git a/CSharpRepl.Services/Roslyn/References/AssemblyReferenceReadme.md b/CSharpRepl.Services/Roslyn/References/AssemblyReferenceReadme.md index 7f59be44..b76b633c 100644 --- a/CSharpRepl.Services/Roslyn/References/AssemblyReferenceReadme.md +++ b/CSharpRepl.Services/Roslyn/References/AssemblyReferenceReadme.md @@ -327,8 +327,10 @@ A `.csx` driven with `csharprepl --eval-file x.csx` (or piped stdin) is the fast - Heavy reflection (`type.GetMethods()`) force-loads every parameter/return type and can surface a deeper `TypeLoadException` ("… does not have an implementation") that the plain call wouldn't — useful signal, but don't mistake it for the original failure. -- Run the test suite *in full* (or alongside a `RoslynServices` test) so `MSBuildLocator` is registered - before NuGet types load (see §4 build note). +- `MSBuildLocator` is registered for the whole test assembly by a `[ModuleInitializer]` + (`TestAssemblyInitializer`), so NuGet/MSBuild types resolve from the SDK even when a single test class is + run in isolation. (Historically you had to run the suite *in full*, or alongside a `RoslynServices` test, + to get this — that's no longer required just for `MSBuildLocator`.) See §4 build note. --- diff --git a/Tests/CSharpRepl.Tests/ConsoleNugetLoggerTests.cs b/Tests/CSharpRepl.Tests/ConsoleNugetLoggerTests.cs new file mode 100644 index 00000000..a3440143 --- /dev/null +++ b/Tests/CSharpRepl.Tests/ConsoleNugetLoggerTests.cs @@ -0,0 +1,141 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System.Threading.Tasks; +using CSharpRepl.Services; +using CSharpRepl.Services.Nuget; +using NSubstitute; +using Spectre.Console; +using Xunit; + +namespace CSharpRepl.Tests; + +/// +/// Unit tests for . These run headless: interactivity is taken from +/// (faked here) rather than the process-global +/// Console.IsOutputRedirected, so both the interactive (spinner/markup) and redirected (plain text) +/// branches can be exercised in-process - no real terminal required. +/// +/// The logger implements NuGet.Common.ILogger, so loading the type pulls in NuGet.Common, +/// which resolves from the SDK only after MSBuildLocator has run; +/// arranges that for the whole assembly, so no RoslynServices-collection membership is needed here. +/// +/// +public class ConsoleNugetLoggerTests +{ + private static ConsoleNugetLogger CreateLogger(bool interactive, bool useUnicode, out FakeConsoleAbstract console, out System.Text.StringBuilder stdout) + { + (console, stdout) = FakeConsole.CreateStubbedOutput(); + // Must be set before the logger reads it in its constructor. + console.IsInteractive.Returns(interactive); + return new ConsoleNugetLogger(console, new Configuration(useUnicode: useUnicode)); + } + + [Fact] + public void Redirected_ProgressGoesToPlainStdout_NotAnsi() + { + var logger = CreateLogger(interactive: false, useUnicode: false, out var console, out var stdout); + + logger.LogInformation(" CACHE https://api.nuget.org/index.json"); + + Assert.Contains("CACHE https://api.nuget.org/index.json", stdout.ToString()); + Assert.Equal("", console.AnsiConsole.Output); + } + + [Fact] + public void Interactive_ProgressGoesToAnsi_NotPlainStdout() + { + var logger = CreateLogger(interactive: true, useUnicode: false, out var console, out var stdout); + + logger.LogInformation(" CACHE https://api.nuget.org/index.json"); + + var ansi = console.AnsiConsole.Output; + Assert.Contains("CACHE", ansi); + Assert.Contains("https://api.nuget.org/index.json", ansi); + Assert.Equal("", stdout.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DebugAndVerbose_AreSuppressed(bool interactive) + { + var logger = CreateLogger(interactive, useUnicode: false, out var console, out var stdout); + + logger.LogDebug("debug noise"); + logger.LogVerbose("verbose noise"); + + Assert.Equal("", stdout.ToString()); + Assert.Equal("", console.AnsiConsole.Output); + } + + [Fact] + public void Interactive_Error_IncludesUnicodePrefix() + { + var logger = CreateLogger(interactive: true, useUnicode: true, out var console, out _); + + logger.LogError("could not restore package 'Foo'"); + + var ansi = console.AnsiConsole.Output; + Assert.Contains("❌", ansi); + Assert.Contains("could not restore package 'Foo'", ansi); + } + + [Fact] + public void Progress_LongLinesAreTruncated() + { + var logger = CreateLogger(interactive: false, useUnicode: false, out _, out var stdout); + + logger.LogInformation("Installing Foo to folder C:\\packages\\foo"); + + var output = stdout.ToString(); + Assert.Contains("Installing Foo", output); + Assert.DoesNotContain("to folder", output); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithStatusAsync_RunsOperationAndReturnsResult(bool interactive) + { + var logger = CreateLogger(interactive, useUnicode: false, out _, out _); + + var ran = false; + var result = await logger.WithStatusAsync("Humanizer", async () => + { + ran = true; + await Task.Yield(); + return 42; + }); + + Assert.True(ran); + Assert.Equal(42, result); + } + + [Fact] + public void Highlight_RendersUrlsBlue() + { + var markup = ConsoleNugetLogger.Highlight(" CACHE https://api.nuget.org/index.json", bodyStyle: "white", quoteStyle: "yellow"); + + Assert.Contains("[blue]https://api.nuget.org/index.json[/]", markup); + Assert.Contains("[white] CACHE [/]", markup); + } + + [Fact] + public void Highlight_RendersQuotedSegmentsWithQuoteStyle() + { + var markup = ConsoleNugetLogger.Highlight("Package 'Humanizer.3.0.10' installed", bodyStyle: "green", quoteStyle: "yellow"); + + Assert.Contains("[yellow]'Humanizer.3.0.10'[/]", markup); + } + + [Fact] + public void Highlight_EscapesMarkupMetacharacters_SoVersionRangesDoNotBreakParsing() + { + var markup = ConsoleNugetLogger.Highlight("downgrade detected from [2.0.0] to [1.0.0]", bodyStyle: "white", quoteStyle: "yellow"); + + Assert.Contains("[[", markup); // '[' is escaped to '[[' so it isn't parsed as a style tag + _ = new Markup(markup); // must be valid Spectre markup (constructor throws otherwise) + } +} diff --git a/Tests/CSharpRepl.Tests/FakeConsole.cs b/Tests/CSharpRepl.Tests/FakeConsole.cs index 27d47df9..5f260f76 100644 --- a/Tests/CSharpRepl.Tests/FakeConsole.cs +++ b/Tests/CSharpRepl.Tests/FakeConsole.cs @@ -265,6 +265,11 @@ public abstract class FakeConsoleAbstract : IConsoleService IAnsiConsole IConsoleService.Ansi => AnsiConsole; + // Overrides the IConsoleService default (which reads the process-global Console.IsOutputRedirected) so + // tests can choose interactive vs. redirected behavior. NSubstitute defaults this to false (redirected / + // non-interactive), matching how the suite runs headless; flip with .IsInteractive.Returns(true). + public abstract bool IsInteractive { get; } + // Substitutable (not routed to the TestConsole) so tests can verify the screen was cleared via // console.Received().Clear(). public abstract void Clear(); diff --git a/Tests/CSharpRepl.Tests/TestAssemblyInitializer.cs b/Tests/CSharpRepl.Tests/TestAssemblyInitializer.cs new file mode 100644 index 00000000..7705c02a --- /dev/null +++ b/Tests/CSharpRepl.Tests/TestAssemblyInitializer.cs @@ -0,0 +1,47 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System; +using System.Runtime.CompilerServices; +using Microsoft.Build.Locator; + +namespace CSharpRepl.Tests; + +internal static class TestAssemblyInitializer +{ + /// + /// Registers MSBuildLocator once, as early as possible (on test-assembly load, before any test runs). + /// + /// Many types here transitively depend on MSBuild / Roslyn-workspaces / NuGet assemblies, which resolve + /// from the installed .NET SDK only after has run. Previously + /// that happened only as a side effect of the first RoslynServices initialization, so any test + /// touching those assemblies (e.g. NuGet restore, the NuGet logger) had to run inside the + /// RoslynServices collection / the full suite and would throw FileNotFoundException in + /// isolation. Doing it in a module initializer satisfies the dependency process-wide, independent of which + /// tests run or in what order, so such tests can also run on their own. + /// + /// + /// This only registers MSBuildLocator; it does not serialize anything. Tests that need to run serially for + /// other process-global reasons (the loader's AssemblyLoadContext.Resolving hooks) still use the + /// RoslynServices collection. Production's own RegisterDefaults call discards its result and + /// swallows the "already registered" exception, so pre-registering here changes no behavior. + /// + /// + [ModuleInitializer] + internal static void RegisterMSBuild() + { + if (MSBuildLocator.IsRegistered) return; + + try + { + MSBuildLocator.RegisterDefaults(); + } + catch (InvalidOperationException) + { + // No SDK discoverable, or a concurrent registration won the race. Tests that genuinely need + // MSBuild surface a clear failure on use; tests that don't are unaffected. Never let a module + // initializer throw - that would fault the entire test assembly. + } + } +}