Category: projects

Watch2–part7–full test

Watch2 NuGet Package

The Watch2 NuGet package extends the functionality of dotnet watch by adding features such as console clearing and delay handling. It outputs the same information as dotnet watch.

1. Wrapper Interfaces

To facilitate testing, it is necessary to wrap various process creation classes from .NET (e.g., Process => ProcessWrapper, ProcessStartInfo => ProcessStartInfoWrapper). This allows for the testing of process outputs. These wrappers are primarily interfaces that can be mocked and redirected to the main class.

Most of these wrappers can be generated automatically using a Roslyn Code Generator.

Rule of Thumb

If the main class you want to wrap contains properties, methods, or events that return or accept instances of another class, you should create a wrapper for that class as well.

Example 1

The Process class has a property StartInfo of type ProcessStartInfo. Therefore, a wrapper for ProcessStartInfo must be created.

Example 2

The Process class has an event OutputDataReceived of type DataReceivedEventArgs. Consequently, a wrapper for DataReceivedEventArgs is required.

2. Rocks and Waiting for an Async Call

When mocking an async method with Rocks, it is essential to wait for the async call to complete.

For example, you must wait for .WaitForExitAsync() to finish before checking the process output.

Watch2–part 6- options

The Watch2 package passes all the command line arguments to the dotnet watch. So how can it have its own settings? Simple: by reading a watch.json file that is in the same folder where it is executing.

1. Testing
I was considering how to perform the testing, and there were two options:

Microsoft File Providers: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/file-providers?view=aspnetcore-8.0

TestableIO: https://github.com/TestableIO/System.IO.Abstractions

I thought that the second option (TestableIO) would be easier to use and more flexible. However, I do not need to test the creation of the file, but rather the content of the file. Therefore, I will use the first option (Microsoft File Providers).

2. Maintain JSON in Sync with C#
I need a solution to transform JSON to C# on the fly (i.e., at compile time). For this, a Roslyn Code Generator can be used. The rscgutils NuGet package will be helpful for this purpose.

Here is the relevant configuration in the project file:

<ItemGroup>
    <PackageReference Include="Microsoft.Extensions.FileProviders.Abstractions" Version="8.0.0" />
    <PackageReference Include="rscgutils" Version="2024.2000.2000" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
    <ProjectReference Include="..\Watch2_Interfaces\Watch2_Interfaces.csproj" />
</ItemGroup>
<ItemGroup>
	<AdditionalFiles Include="options.gen.json" />
</ItemGroup>

Code in Program.cs
The code in Program.cs will utilize the above configurations and packages. Here is an example of how it might look:

This example demonstrates how to read the watch.json file using the Microsoft File Providers package. The rscgutils package will handle the transformation of JSON to C# at compile time, ensuring that the JSON configuration is always in sync with the C# code.

So the code in program.cs will be:

//test
//args = ["run --no-hot-reload"];
//string folder = @"D:\gth\RSCG_Examples\v2\Generator";

//uncomment this line for production
string folder = Environment.CurrentDirectory;

var serviceCollection = new ServiceCollection();
ConfigureServices(serviceCollection, folder);

var serviceProvider = serviceCollection.BuildServiceProvider();

var console = serviceProvider.GetRequiredService<IConsoleWrapper>();
var processManager = serviceProvider.GetRequiredService<ProcessManager>();
var startInfo = serviceProvider.GetRequiredService<IProcessStartInfo>();

var fileOptions = serviceProvider.GetRequiredService<IOptionsReader>();
if(!fileOptions.ExistsFile())
{
    var file = Path.Combine(folder, "watch2.json");
    File.WriteAllText(file, MyAdditionalFiles.options_gen_json);    
}

await processManager.StartProcessAsync(args, console, startInfo);



void ConfigureServices(IServiceCollection services,string folder)
{
    services.AddSingleton<IFileProvider>(new PhysicalFileProvider(folder));
    services.AddSingleton<IOptionsReader, OptionsReader>();
    services.AddSingleton<Ioptions_gen_json>(it =>
    {
        var optionsReader = it.GetRequiredService<IOptionsReader>();
        return optionsReader.GetOptions() ?? options_gen_json.Empty;
    });
    services.AddSingleton<IConsoleWrapper, ConsoleWrapper>();
    services.AddSingleton<ProcessManager, ProcessManager>();
    services.AddSingleton<IProcessStartInfo>(provider => new ProcessStartInfoWrapper
    {
    FileName = "dotnet",
    Arguments = "watch " + string.Join(' ', args),
    WorkingDirectory = folder,
    RedirectStandardOutput = true,
    RedirectStandardInput = true,
    RedirectStandardError = true,
    UseShellExecute = false,
    CreateNoWindow = true
    });

    services.AddLogging(loggingBuilder =>
    {
        loggingBuilder.ClearProviders();
        loggingBuilder.SetMinimumLevel(LogLevel.Trace);
        loggingBuilder.AddNLog("nlog.config");
    });
    services.AddSingleton<ILogger<ProcessManager>, Logger<ProcessManager>>();
    services.AddSingleton<Func<IProcessStartInfo, IProcessWrapper>>(it => new ProcessWrapper(it));
}

Watch2- part 5–DI

The Watch2 NuGet package project now has interfaces, making it test-friendly and ready for action.

To make life easier, we’ve sprinkled some Dependency Injection (DI) magic throughout the project.

Classes have been refactored to embrace constructor injection, ensuring all necessary interfaces are on board.

The DI container has been set up in `Program.cs`, making dependency resolution a breeze. The tests have also been given a makeover to fit the new DI style.

This is a never-ending journey, but hey, it’s a pretty good start!

The program.cs now looks like this:



var serviceCollection = new ServiceCollection();
ConfigureServices(serviceCollection);

var serviceProvider = serviceCollection.BuildServiceProvider();

var console = serviceProvider.GetRequiredService<IConsoleWrapper>();
var processManager = serviceProvider.GetRequiredService<ProcessManager>();
var startInfo = serviceProvider.GetRequiredService<IProcessStartInfo>();

await processManager.StartProcessAsync(args, console, startInfo);

void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IConsoleWrapper, ConsoleWrapper>();
services.AddSingleton<ProcessManager, ProcessManager>();
services.AddSingleton<IProcessStartInfo>(provider => new ProcessStartInfoWrapper
{
FileName = "dotnet",
Arguments = "watch " + string.Join(' ', args),
WorkingDirectory = Environment.CurrentDirectory,
RedirectStandardOutput = true,
RedirectStandardInput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
});

    services.AddLogging(loggingBuilder =>
    {
        loggingBuilder.ClearProviders();
        loggingBuilder.SetMinimumLevel(LogLevel.Trace);
        loggingBuilder.AddNLog("nlog.config");
    });
}

Watch2–part 4–CI and CD

Now I want to do build and auto-deployment as a Nuget Package( at https://www.nuget.org/packages/watch2 )

The most simple build action on GitHub Actions is simple example of how to do it.

name: .NET

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
      with:
        fetch-depth: 0

    - name: Setup .NET 8
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x
    
    - name: build
      run: |
        cd src 
        cd Watch2
        dotnet restore
        dotnet build 
        
        

However, I want to do more than that. I want to build , test and deploy on demand the Nuget package to Nuget.org.

However , the building should be done first on local – and then with GitHub Actions – the deployment should be done.

For this I do like https://github.com/xt0rted/dotnet-run-script

This is how I use to do test and pack, by using a global.json file

{
  "scripts": {
    "build": "dotnet build --configuration Release",
    "test": "dotnet test --configuration Release",
    "ci": "dotnet r build && dotnet r test",
    "pack":"dotnet r ci && cd Watch2 && dotnet pack -o ../../nugetPackages"
  }
}

So the github action now looks like this

name: compile and deploy with tag v*

on:
  push:
    branches: [ "main" ]
    tags: [ 'v*' ] # Listen for tag pushes that match version tags
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    # runs-on: windows-latest
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x

    - name: Restore dependencies
      run: |
        dotnet tool install --global PowerShell
        cd src 
        cd Watch2
        dotnet restore
        
    - name: Build
      run: |
        cd src
        cd Watch2
        dotnet tool restore
        dotnet r pack

    - name: 'Upload nuget'
      uses: actions/upload-artifact@v4
      with:
        name: Nuget_${{github.run_number}}
        path: src/nugetPackages
        retention-days: 1
  
    - name: push to nuget
      if: startsWith(github.ref, 'refs/tags/v') # This line ensures the step runs only if a tag version is present
      run: |
        dir src/nugetPackages/*.*
        echo '1'
        dir src/nugetPackages/*.symbols.nupkg  
        echo '2'
        cd src
        cd nugetPackages
        dotnet nuget push "*.symbols.nupkg" --api-key  ${{ secrets.NUGET_KEY  }} --source https://api.nuget.org/v3/index.json
    

More, we should add to github Settings => Secrets and variables => Actions secrets a new secret with name NUGET_KEY

The build and test are made automatically . More, if I want to deploy also, then
1. I modify the version in the csproj file

	<PropertyGroup>
		<Version>8.2024.1102.950</Version>
	</PropertyGroup>

2. I push the changes to the repository with a tag ( e.g. v8.2024.1102.950)

So this achieves the CI/CD basic .

Watch2- part 3 -refactoring to interfaces and test

The Watch2 just intercepts the dotnet watch, adds a clear console, and a delay to give a breather between running the app and the tests.

So I need to have tests – if I want to be sure what the software does and to modify it without summoning the coding gremlins. More importantly, the output from dotnet watch could be different – so I need more tests .

The solution evolved from

to

The main points learned from the process of refactoring:

1. GitHub Copilot is like having a coding sidekick. Some modifications I made manually, but most of the code was generated by my new best friend.

2. For each class that doesn’t have an interface (like ProcessStartInfo, Process, Console), a Wrapper interface must be created. Because who doesn’t love wrapping things up?

3. It is very hard to make interfaces dependent just on interfaces – not on implementations.

4. Static variables could impact the test !

5. The Rocks nuget package really rocks for generating mocks

[assembly: Rock(typeof(IProcessWrapper), BuildType.Create)]
[assembly: Rock(typeof(IConsoleWrapper), BuildType.Create)]
[assembly: Rock(typeof(IDataReceivedEventArgs), BuildType.Make)]
[TestMethod]
public void TestRestart()
{
    // Arrange
    var mockProcess = new IProcessWrapperCreateExpectations();
    var mockConsole = new IConsoleWrapperCreateExpectations();
    var mockDataReceivedEventArgs = new IDataReceivedEventArgsMakeExpectations();
    mockProcess.Methods.Kill().ExpectedCallCount ( 1);
    mockConsole.Methods.WriteLine(Rocks.Arg.Any<string>()).Callback(it=> { });
    

    // Act
    (new ProcessManager()).HandleOutput(("Waiting for a file to change before"), mockConsole.Instance());

    // Assert
    mockProcess.Verify();
}

Watch2- part 2- first fast implementation

The first attempt to writing the code was very fast – and, surely, not testable. (Speedy Gonzales would be proud!)

Some observations:

1. If you start any process inside a console application, you should intercept the cancel signal and stop the process.
This is done by using the CancelKeyPress event of the Console class.
This is important because the user can cancel the process by pressing Ctrl+C.
(Because who doesn’t love a good Ctrl+C in the morning?)

Console.CancelKeyPress += delegate {
    // call methods to clean up
    Kill(proc);
};

2. If you want to stop the dotnet watch after it detects a change in the file system, you cannot. You need to kill the process and start it again. So, you need to kill the process and start it again.
(It’s like a phoenix, it must die to be reborn!)

3. Spectre.Console is a really good library to create console applications with a lot of features. I used it to create messages with different colors and styles.
(Because plain text is so last century.)

4. The code is very simple and easy to understand. I used the Process class to start the dotnet watch process and the Console class to write messages to the console.
(Simple and easy, just like making a cup of coffee… if you have a coffee machine.)

This is the final code:

using Spectre.Console;
using System.Diagnostics;
Process? proc = null;
Console.CancelKeyPress += delegate {
    // call methods to clean up
    Kill(proc);
};
bool shouldWait = false;
while (true)
{
    ProcessStartInfo startInfo = new ProcessStartInfo();
    startInfo.FileName = "dotnet";
    startInfo.Arguments = "watch " + string.Join(' ', args);
    //startInfo.LoadUserProfile = true;
    startInfo.WorkingDirectory = Environment.CurrentDirectory;

    startInfo.RedirectStandardOutput = true;
    startInfo.RedirectStandardInput = true;
    startInfo.RedirectStandardError = true;
    startInfo.UseShellExecute = false;
    startInfo.CreateNoWindow = true;

    proc = new Process();
    proc.StartInfo = startInfo;
    proc.OutputDataReceived += (sender, e) =>
    {
        if (shouldWait)
        {
            shouldWait = false;
            Kill(proc);
            Thread.Sleep(15_000);
            return;

        }
        var line = e.Data;
        if (line == null) return;
        if (line.Contains("dotnet watch"))
        {
            if (line.Contains("Started"))
            {
                Console.Clear();
            }
            if (line.Contains("Building"))
            {

            }
        }

        if (line.Contains(": error "))
        {
            AnsiConsole.MarkupLineInterpolated($"->[bold red]:cross_mark: {e.Data}[/]");
            return;
        }
        if (line.Contains("Waiting for a file to change before"))
        {
            Console.WriteLine("wain");
            shouldWait = true;

        }

        Console.WriteLine("->" + e.Data);


    };
    proc.ErrorDataReceived += (sender, e) =>
    {
        if (e.Data != null)
        {
            AnsiConsole.MarkupLineInterpolated($"->[bold red]:cross_mark: {e.Data}[/]");
            return;

        }
    };

    AnsiConsole.MarkupLineInterpolated($"[bold green]Starting...[/]");
    proc.Start();
    Console.Clear();
    proc.BeginOutputReadLine();
    proc.BeginErrorReadLine();
    await proc.WaitForExitAsync();
}

static void Kill(Process? proc)
{
    if (proc == null) return;
    if (proc.HasExited) return;
    proc.Kill(true);
    proc.Close();
    proc = null;
}

Watch2–part 1–idea

I frequently use .NET Watch for two main reasons:

  1. Running .NET applications during development.

  2. Executing tests.

Seriously, if you haven’t tried .NET Watch, you’re missing out. It’s like having a personal assistant who never sleeps compiling code . For more info, check out Microsoft’s documentation  at https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch

But, like all good things, it comes with a couple of quirks:

  1. The console output scrolls faster than a cat on a hot tin roof, making it hard to keep up. A Console.Clear (or cls command) would be a lifesaver to reset the console after each file change.

  2. Running dotnet watch for both the application and tests at the same time is like trying to juggle flaming torches—compilation conflicts galore! You’ll often find yourself stopping one of the watches to keep things from going up in flames.

To tackle these issues, I whipped up a simple .NET tool, named Watch2.  This little gem can clear the console and run the watch command with an optional delay. It’s built with .NET Core and can be installed as a global tool.

For more laughs and details, visit the NuGet package page  at https://www.nuget.org/packages/watch2 .

NetCoreUsefullEndpoints-part 13–adding runtime information

In the Nuget NetCoreUsefullEndpoints I have added information about the runtime information :

You can access by going to localhost:5027/api/usefull/runtimeinformationAll and the end result is

{
“frameworkDescription”: “.NET 8.0.8”,
“osDescription”: “Microsoft Windows 10.0.22631”,
“processArchitecture”: “X64”,
“osArchitecture”: “X64”
}

The code that returns this is

route.MapGet("api/usefull/runtimeinformation/", (HttpContext httpContext) =>
        {
            return TypedResults.Ok(new Helper().FromStaticRuntimeInformation());
        })

( I have used a Roslyn Code Generator , https://ignatandrei.github.io/RSCG_Examples/v2/docs/RSCG_Static#example–source-csproj-source-files- , that generates a class from a static class )

NetCoreUsefullEndpoints-part 12–adding url adresses

In the Nuget NetCoreUsefullEndpoints I have added information about the current process :

You can access by going to localhost:5027/api/usefull/adresses and the end result is

[
     “http://localhost:5027″
]

The code that returns this is

route.MapGet("api/usefull/adresses", (HttpContext httpContext, [FromServices] IServer server) =>
        {
            var adresses = server.Features.Get<IServerAddressesFeature>();
            var ret= adresses?.Addresses?.ToArray()??[] ;
            return TypedResults.Ok(ret);
        });

Andrei Ignat weekly software news(mostly .NET)

* indicates required

Please select all the ways you would like to hear from me:

You can unsubscribe at any time by clicking the link in the footer of our emails. For information about our privacy practices, please visit our website.

We use Mailchimp as our marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp's privacy practices here.