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.
+ }
+ }
+}