< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Razor.PowerShellRazorPage
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Razor/PowerShellRazorPage.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
90%
Covered lines: 127
Uncovered lines: 13
Coverable lines: 140
Total lines: 451
Line coverage: 90.7%
Branch coverage
82%
Covered branches: 66
Total branches: 80
Branch coverage: 82.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 09/08/2025 - 20:34:03 Line coverage: 85.5% (118/138) Branch coverage: 68% (34/50) Total lines: 329 Tag: Kestrun/Kestrun@3790ee5884494a7a2a829344a47743e0bf492e7212/18/2025 - 21:41:58 Line coverage: 92.3% (120/130) Branch coverage: 84.2% (59/70) Total lines: 428 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff01/24/2026 - 19:35:59 Line coverage: 90.7% (127/140) Branch coverage: 82.5% (66/80) Total lines: 451 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51 09/08/2025 - 20:34:03 Line coverage: 85.5% (118/138) Branch coverage: 68% (34/50) Total lines: 329 Tag: Kestrun/Kestrun@3790ee5884494a7a2a829344a47743e0bf492e7212/18/2025 - 21:41:58 Line coverage: 92.3% (120/130) Branch coverage: 84.2% (59/70) Total lines: 428 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff01/24/2026 - 19:35:59 Line coverage: 90.7% (127/140) Branch coverage: 82.5% (66/80) Total lines: 451 Tag: Kestrun/Kestrun@f59dcba478ea75f69584d696e5f1fb1cfa40aa51

Metrics

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Razor/PowerShellRazorPage.cs

#LineLine coverage
 1using System.Management.Automation;
 2using Kestrun.Scripting;
 3using Kestrun.Models;
 4using Kestrun.Languages;
 5using Kestrun.Utilities;
 6using Serilog;
 7using Serilog.Events;
 8using Kestrun.Logging;
 9using Kestrun.Hosting;
 10
 11namespace Kestrun.Razor;
 12
 13/// <summary>
 14/// Provides middleware for enabling PowerShell-backed Razor Pages, allowing execution of a sibling PowerShell script (*
 15/// </summary>
 16/// <remarks>
 17/// This middleware allows for dynamic content generation in Razor Pages by leveraging PowerShell scripts.
 18/// Middleware that lets any Razor view (*.cshtml) load a sibling PowerShell
 19/// script (*.cshtml.ps1) in the SAME request.  The script can set `$Model` which
 20/// then becomes available to the Razor page through HttpContext.Items.
 21/// -----------------------------------------------------------------------------
 22///
 23/// Usage (inside KestrunHost.ApplyConfiguration):
 24///     builder.Services.AddRazorPages();                    // already present
 25///     …
 26/// /*  AFTER you build App and create _runspacePool:  */
 27///
 28///     App.UsePowerShellRazorPages(_runspacePool);
 29///
 30/// That’s it – no per-page registration.
 31/// </remarks>
 32public static class PowerShellRazorPage
 33{
 34    private const string MODEL_KEY = "PageModel";
 35    private const string StringsItemKey = "KrStrings";
 36    private const string LocalizerItemKey = "KrLocalizer";
 37
 38    /// <summary>
 39    /// Enables <c>.cshtml</c> + <c>.cshtml.ps1</c> pairs.
 40    /// For a request to <c>/Foo</c> it will, in order:
 41    /// <list type="number">
 42    ///   <item><description>Look for <c>Pages/Foo.cshtml</c></description></item>
 43    ///   <item><description>If a <c>Pages/Foo.cshtml.ps1</c> exists, execute it
 44    ///       in the supplied runspace-pool</description></item>
 45    ///   <item><description>Whatever the script assigns to <c>$Model</c>
 46    ///       is copied to <c>HttpContext.Items["PageModel"]</c></description></item>
 47    /// </list>
 48    /// Razor pages (or a generic <see cref="PwshKestrunModel"/>) can then
 49    /// read that dynamic object.
 50    /// </summary>
 51    /// <param name="app">The <see cref="WebApplication"/> pipeline.</param>
 52    /// <param name="pool">Kestrun’s shared <see cref="KestrunRunspacePoolManager"/>.</param>
 53    /// <param name="rootPath">Optional root path for Razor Pages. Defaults to 'Pages'.</param>
 54    /// <returns><paramref name="app"/> for fluent chaining.</returns>
 55    public static IApplicationBuilder UsePowerShellRazorPages(
 56        this IApplicationBuilder app,
 57        KestrunRunspacePoolManager pool,
 58        string? rootPath = null)
 59    {
 960        ArgumentNullException.ThrowIfNull(app);
 961        ArgumentNullException.ThrowIfNull(pool);
 62        // Log setup
 963        var logger = pool.Host.Logger;
 64        // Check if Debug level is enabled once
 965        var isDebugLevelEnabled = logger.IsEnabled(LogEventLevel.Debug);
 66
 67        // Log middleware configuration
 968        if (isDebugLevelEnabled)
 69        {
 770            logger.Debug("Configuring PowerShell Razor Pages middleware");
 71        }
 72
 973        var env = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
 974        var pagesRoot = string.IsNullOrWhiteSpace(rootPath)
 975            ? Path.Combine(env.ContentRootPath, "Pages")
 976            : rootPath;
 977        logger.Information("Using Pages directory: {Path}", pagesRoot);
 978        if (!Directory.Exists(pagesRoot))
 79        {
 180            logger.Warning("Pages directory not found: {Path}", pagesRoot);
 81        }
 82
 83        // MUST run before MapRazorPages()
 984        _ = app.Use(async (context, next) =>
 985        {
 786            await ProcessPowerShellRazorPageRequestAsync(context, next, pagesRoot, pool, logger, isDebugLevelEnabled).Co
 1687        });
 88
 89        // static files & routing can be added earlier in pipeline
 90
 991        _ = app.UseRouting();
 1892        _ = app.UseEndpoints(e => e.MapRazorPages());
 993        return app;
 94    }
 95
 96    /// <summary>
 97    /// Processes a PowerShell Razor Page request by evaluating path conditions, executing the PowerShell script, and ma
 98    /// </summary>
 99    /// <param name="context">The HTTP context for the current request.</param>
 100    /// <param name="next">The next middleware in the pipeline.</param>
 101    /// <param name="pagesRoot">The root directory for Razor Pages.</param>
 102    /// <param name="pool">The runspace pool manager for PowerShell execution.</param>
 103    /// <param name="logger">The logger instance for diagnostic output.</param>
 104    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 105    /// <returns>A task representing the asynchronous operation.</returns>
 106    private static async Task ProcessPowerShellRazorPageRequestAsync(
 107        HttpContext context, RequestDelegate next, string pagesRoot,
 108        KestrunRunspacePoolManager pool, Serilog.ILogger logger, bool isDebugLevelEnabled)
 109    {
 7110        if (isDebugLevelEnabled)
 111        {
 7112            logger.DebugSanitized("Processing PowerShell Razor Page request for {Path}", context.Request.Path);
 113        }
 114
 7115        var relPath = GetRelativePath(context);
 7116        if (relPath is null)
 117        {
 0118            await next(context);
 0119            return;
 120        }
 121
 7122        var (view, psfile, csfile) = BuildCandidatePaths(pagesRoot, relPath);
 123
 124        // Skip if code-behind or files don't exist
 7125        if (ShouldSkipProcessing(csfile, view, psfile))
 126        {
 3127            await next(context);
 3128            return;
 129        }
 130
 131        // Execute the PowerShell script and handle response
 4132        await ExecutePowerShellRazorPageAsync(context, next, psfile, pool, logger, isDebugLevelEnabled);
 7133    }
 134
 135    /// <summary>
 136    /// Determines whether to skip processing based on code-behind presence or file existence.
 137    /// </summary>
 138    /// <param name="codeBehindFile">The path to the potential C# code-behind file.</param>
 139    /// <param name="viewFile">The path to the Razor view file.</param>
 140    /// <param name="powershellFile">The path to the PowerShell script file.</param>
 141    /// <returns>True if processing should be skipped; otherwise, false.</returns>
 142    private static bool ShouldSkipProcessing(string codeBehindFile, string viewFile, string powershellFile) =>
 7143        HasCodeBehind(codeBehindFile) || !FilesExist(viewFile, powershellFile);
 144
 145    /// <summary>
 146    /// Executes a PowerShell Razor Page script and processes the response.
 147    /// </summary>
 148    /// <param name="context">The HTTP context for the current request.</param>
 149    /// <param name="next">The next middleware in the pipeline.</param>
 150    /// <param name="psFilePath">The path to the PowerShell script file to execute.</param>
 151    /// <param name="pool">The runspace pool manager for PowerShell execution.</param>
 152    /// <param name="logger">The logger instance for diagnostic output.</param>
 153    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 154    /// <returns>A task representing the asynchronous operation.</returns>
 155    private static async Task ExecutePowerShellRazorPageAsync(
 156        HttpContext context, RequestDelegate next, string psFilePath,
 157        KestrunRunspacePoolManager pool, Serilog.ILogger logger, bool isDebugLevelEnabled)
 158    {
 4159        PowerShell? ps = null;
 160        try
 161        {
 4162            ps = CreatePowerShell(pool);
 4163            PrepareSession(ps, context, pool.Host);
 4164            await AddScriptFromFileAsync(ps, psFilePath, context.RequestAborted);
 3165            LogExecution(psFilePath);
 166
 3167            var psResults = await InvokePowerShellWithAbortAsync(ps, context, logger, isDebugLevelEnabled).ConfigureAwai
 3168            if (psResults is null)
 169            {
 0170                return;
 171            }
 172
 173            // Process results and continue pipeline
 3174            LogResultsCount(psResults.Count);
 3175            SetModelIfPresent(ps, context);
 176
 3177            if (context.RequestAborted.IsCancellationRequested)
 178            {
 0179                return;
 180            }
 181
 3182            if (HasErrors(ps))
 183            {
 1184                await HandleErrorsAsync(context, ps);
 1185                return;
 186            }
 187
 2188            LogStreamsIfAny(ps);
 2189            await next(context);
 190
 2191            if (isDebugLevelEnabled)
 192            {
 2193                logger.DebugSanitized("PowerShell Razor Page completed for {Path}", context.Request.Path);
 194            }
 2195        }
 1196        catch (Exception ex)
 197        {
 1198            logger.ErrorSanitized(ex, "Error occurred in PowerShell Razor Page middleware for {Path}", context.Request.P
 1199        }
 200        finally
 201        {
 4202            ReturnRunspaceAndDispose(ps, pool);
 203        }
 4204    }
 205
 206    /// <summary>
 207    /// Invokes a PowerShell instance with request cancellation support.
 208    /// </summary>
 209    /// <param name="ps">The PowerShell instance to invoke.</param>
 210    /// <param name="context">The HTTP context containing the cancellation token.</param>
 211    /// <param name="logger">The logger instance for diagnostic output.</param>
 212    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 213    /// <returns>A collection of PowerShell results, or null if the request was cancelled or an error occurred.</returns
 214    private static async Task<PSDataCollection<PSObject>?> InvokePowerShellWithAbortAsync(
 215        PowerShell ps, HttpContext context, Serilog.ILogger logger, bool isDebugLevelEnabled)
 216    {
 217        try
 218        {
 3219            return await ps.InvokeWithRequestAbortAsync(
 3220                context.RequestAborted,
 0221                onAbortLog: () => logger.DebugSanitized("Request aborted; stopping PowerShell pipeline for {Path}", cont
 3222            ).ConfigureAwait(false);
 223        }
 0224        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
 225        {
 0226            if (isDebugLevelEnabled)
 227            {
 0228                logger.DebugSanitized("PowerShell pipeline cancelled due to request abortion for {Path}", context.Reques
 229            }
 230            // Client went away; don't try to write an error response.
 0231            return null;
 232        }
 3233    }
 234
 235    /// <summary>
 236    /// Gets the relative path for the PowerShell Razor Page from the HTTP context.
 237    /// </summary>
 238    /// <param name="context">The HTTP context.</param>
 239    /// <returns>The relative path for the PowerShell Razor Page.</returns>
 240    private static string? GetRelativePath(HttpContext context)
 241    {
 7242        var relPath = context.Request.Path.Value?.Trim('/');
 7243        if (string.IsNullOrEmpty(relPath))
 244        {
 1245            if (Log.IsEnabled(LogEventLevel.Debug))
 246            {
 1247                Log.Debug("Request path is empty, defaulting to Index for PowerShell Razor Page processing");
 248            }
 249
 1250            relPath = "Index";
 251        }
 7252        relPath = relPath.Replace('/', Path.DirectorySeparatorChar);
 7253        if (Log.IsEnabled(LogEventLevel.Debug))
 254        {
 7255            Log.Debug("Transformed request path to relative: {RelPath}", relPath);
 256        }
 257
 7258        return relPath;
 259    }
 260
 261    /// <summary>
 262    /// Builds the candidate file paths for a PowerShell Razor Page.
 263    /// </summary>
 264    /// <param name="pagesRoot">The root directory for the Razor Pages.</param>
 265    /// <param name="relPath">The relative path for the Razor Page.</param>
 266    /// <returns>The candidate file paths for the Razor Page.</returns>
 267    private static (string view, string psfile, string csfile) BuildCandidatePaths(string pagesRoot, string relPath)
 268    {
 7269        var view = Path.Combine(pagesRoot, relPath + ".cshtml");
 7270        var psfile = view + ".ps1";
 7271        var csfile = view + ".cs";
 7272        return (view, psfile, csfile);
 273    }
 274
 275    /// <summary>
 276    /// Checks if the C# code-behind file exists.
 277    /// </summary>
 278    /// <param name="csfile">The path to the C# code-behind file.</param>
 279    /// <returns>True if the code-behind file exists; otherwise, false.</returns>
 280    private static bool HasCodeBehind(string csfile)
 281    {
 7282        if (File.Exists(csfile))
 283        {
 1284            if (Log.IsEnabled(LogEventLevel.Debug))
 285            {
 1286                Log.Debug("Found C# code-behind file: {CsFile}", csfile);
 287            }
 288
 1289            return true;
 290        }
 6291        return false;
 292    }
 293
 294    /// <summary>
 295    /// Checks if the PowerShell Razor Page files exist.
 296    /// </summary>
 297    /// <param name="view">The path to the Razor view file.</param>
 298    /// <param name="psfile">The path to the PowerShell script file.</param>
 299    /// <returns>True if the files exist; otherwise, false.</returns>
 300    private static bool FilesExist(string view, string psfile)
 301    {
 6302        var ok = File.Exists(view) && File.Exists(psfile);
 6303        if (!ok && Log.IsEnabled(LogEventLevel.Debug))
 304        {
 2305            Log.Debug("PowerShell Razor Page files not found: {View} or {PsFile}", view, psfile);
 306        }
 307
 6308        return ok;
 309    }
 310
 311    /// <summary>
 312    /// Creates a PowerShell instance from the runspace pool.
 313    /// </summary>
 314    /// <param name="pool">The runspace pool manager.</param>
 315    /// <returns>The PowerShell instance.</returns>
 316    private static PowerShell CreatePowerShell(KestrunRunspacePoolManager pool)
 4317        => PowerShell.Create(pool.Acquire());
 318
 319    /// <summary>
 320    /// Prepares the PowerShell session with the HTTP context.
 321    /// </summary>
 322    /// <param name="ps">The PowerShell instance.</param>
 323    /// <param name="context">The HTTP context.</param>
 324    /// <param name="host">The Kestrun host.</param>
 325    private static void PrepareSession(PowerShell ps, HttpContext context, KestrunHost host)
 326    {
 4327        var krContext = new KestrunContext(host, context);
 4328        context.Items[PowerShellDelegateBuilder.KR_CONTEXT_KEY] = krContext;
 329
 4330        if (!context.Items.ContainsKey(LocalizerItemKey))
 331        {
 4332            context.Items[LocalizerItemKey] = context.Items.TryGetValue(StringsItemKey, out var strings) && strings is I
 4333                ? localizedStrings
 4334                : krContext.LocalizedStrings;
 335        }
 336
 4337        var ss = ps.Runspace.SessionStateProxy;
 4338        ss.SetVariable("Context", krContext);
 4339        if (context.Items.TryGetValue("KrLocalizer", out var localizer))
 340        {
 4341            ss.SetVariable("Localizer", localizer);
 342        }
 4343        if (krContext.HasRequestCulture)
 344        {
 0345            PowerShellExecutionHelpers.AddCulturePrelude(ps, krContext.Culture, host.Logger);
 346        }
 4347        ss.SetVariable("Model", null);
 4348    }
 349
 350    /// <summary>
 351    /// Adds a PowerShell script from a file to the PowerShell instance.
 352    /// </summary>
 353    /// <param name="ps">The PowerShell instance.</param>
 354    /// <param name="path">The path to the script file.</param>
 355    /// <param name="token">The cancellation token.</param>
 356    private static async Task AddScriptFromFileAsync(PowerShell ps, string path, CancellationToken token)
 357    {
 4358        var script = await File.ReadAllTextAsync(path, token).ConfigureAwait(false);
 3359        _ = ps.AddScript(script);
 3360    }
 361
 362    /// <summary>
 363    /// Logs the execution of a PowerShell script.
 364    /// </summary>
 365    /// <param name="psfile">The path to the PowerShell script file.</param>
 366    private static void LogExecution(string psfile)
 367    {
 3368        if (Log.IsEnabled(LogEventLevel.Debug))
 369        {
 3370            Log.Debug("Executing PowerShell script: {ScriptFile}", psfile);
 371        }
 3372    }
 373
 374    /// <summary>
 375    /// Logs the count of results returned by the PowerShell script.
 376    /// </summary>
 377    /// <param name="count">The number of results returned by the PowerShell script.</param>
 378    private static void LogResultsCount(int count)
 379    {
 3380        if (Log.IsEnabled(LogEventLevel.Debug))
 381        {
 3382            Log.Debug("PowerShell script returned {Count} results", count);
 383        }
 3384    }
 385
 386    /// <summary>
 387    /// Sets the model in the HttpContext if present in the PowerShell session.
 388    /// </summary>
 389    /// <param name="ps">The PowerShell instance.</param>
 390    /// <param name="context">The HTTP context.</param>
 391    private static void SetModelIfPresent(PowerShell ps, HttpContext context)
 392    {
 3393        var model = ps.Runspace.SessionStateProxy.GetVariable("Model");
 3394        if (model is not null)
 395        {
 2396            context.Items[MODEL_KEY] = model;
 397        }
 398
 3399        if (Log.IsEnabled(LogEventLevel.Debug))
 400        {
 3401            Log.Debug("PowerShell Razor Page model set: {Model}", model);
 402        }
 3403    }
 404
 3405    private static bool HasErrors(PowerShell ps) => ps.HadErrors || ps.Streams.Error.Count != 0;
 406
 407    private static async Task HandleErrorsAsync(HttpContext context, PowerShell ps)
 408    {
 1409        Log.Error("PowerShell script encountered errors: {ErrorCount}", ps.Streams.Error.Count);
 1410        if (Log.IsEnabled(LogEventLevel.Debug))
 411        {
 1412            Log.Debug("PowerShell script errors: {Errors}", BuildError.Text(ps));
 413        }
 414
 1415        await BuildError.ResponseAsync(context, ps);
 1416    }
 417
 418    private static void LogStreamsIfAny(PowerShell ps)
 419    {
 2420        if (ps.Streams.Verbose.Count > 0 || ps.Streams.Debug.Count > 0 || ps.Streams.Warning.Count > 0 || ps.Streams.Inf
 421        {
 0422            Log.Verbose("PowerShell script completed with verbose/debug/warning/info messages.");
 0423            Log.Verbose(BuildError.Text(ps));
 424        }
 2425        else if (Log.IsEnabled(LogEventLevel.Debug))
 426        {
 2427            Log.Debug("PowerShell script completed without errors or messages.");
 428        }
 2429    }
 430
 431    private static void ReturnRunspaceAndDispose(PowerShell? ps, KestrunRunspacePoolManager pool)
 432    {
 4433        if (ps is null)
 434        {
 0435            return;
 436        }
 437
 4438        if (Log.IsEnabled(LogEventLevel.Debug))
 439        {
 4440            Log.Debug("Returning runspace to pool: {RunspaceId}", ps.Runspace.InstanceId);
 441        }
 442
 4443        pool.Release(ps.Runspace);
 4444        if (Log.IsEnabled(LogEventLevel.Debug))
 445        {
 4446            Log.Debug("Disposing PowerShell instance: {InstanceId}", ps.InstanceId);
 447        }
 448
 4449        ps.Dispose();
 4450    }
 451}