From 734f29f6468d520458f80490035c5d041415240d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 3 Nov 2025 20:29:48 +0200 Subject: [PATCH 001/105] Miscellaneous --- .editorconfig | 9 +- .gitignore | 494 +++++++++++++++++++++++++++++++++++++++++++++++++- Agents.md | 86 +++++++++ 3 files changed, 579 insertions(+), 10 deletions(-) create mode 100644 Agents.md diff --git a/.editorconfig b/.editorconfig index 1b7d88b..30024f0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -77,12 +77,17 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] # analyzers -dotnet_diagnostic.IDE0290.severity = none # use primary constuctor +dotnet_diagnostic.IDE0290.severity = none # use primary constructor dotnet_diagnostic.IDE0028.severity = none # use collection expression dotnet_diagnostic.IDE0056.severity = none # simplify index operator dotnet_diagnostic.IDE0057.severity = none # use range operator +dotnet_diagnostic.IDE0301.severity = none # simplify collection initialization +dotnet_diagnostic.IDE0053.severity = none # expression body lambda +dotnet_diagnostic.IDE0046.severity = none # simplify if(s) - conditional operator +dotnet_diagnostic.IDE0305.severity = none # [, ...] instead of .ToArray() -# namespace decleration + +# namespace declaration csharp_style_namespace_declarations = file_scoped:warning # var preferences diff --git a/.gitignore b/.gitignore index 29a4773..ada3b75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,486 @@ -bin/ -obj/ -/packages/ -riderModule.iml -/_ReSharper.Caches/ -/.idea -.vscode -._* \ No newline at end of file +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp +.vscode/launch.json +.vscode/tasks.json diff --git a/Agents.md b/Agents.md new file mode 100644 index 0000000..5a82d1e --- /dev/null +++ b/Agents.md @@ -0,0 +1,86 @@ +# Agents + +## Overview +- Pulse is a ConsoleAppFramework-driven CLI that stress-tests HTTP endpoints using JSON-defined request recipes. +- The codebase is organized into small, purpose-built agents that coordinatedly parse configuration, execute requests, collect telemetry, and export results. +- This document captures the role, location, and interactions of every agent discovered during the repository scan performed on November 3, 2025. + +## Runtime Agents + +### Command & Orchestration Agent +- **Files:** `src/Pulse/Program.cs`, `src/Pulse/Core/Commands.cs` +- Registers the command surface (`Pulse`, `get-sample`, `get-schema`, `update`, `terms-of-use`) and wires a global exception filter. +- `Commands.Root` parses CLI inputs into `ParametersBase`, loads request definitions from disk via `InputJsonContext`, and triggers the execution pipeline. +- Hosts helper commands that generate request samples and JSON Schema artifacts, print terms of use, and query GitHub releases for updates. + +### Configuration Agent +- **Files:** `src/Pulse/Configuration/InputJsonContext.cs`, `src/Pulse/Configuration/DefaultJsonContext.cs`, `src/Pulse/Configuration/Parameters.cs`, `src/Pulse/Core/RequestDetails.cs` +- Source generators (`JsonSerializerContext`) provide strongly typed serializers for request payloads, headers, exceptions, and release metadata. +- `Parameters` (and its base) capture run-time tuning knobs (request count, timeout, concurrency, export flags, verbosity, output directory, cancellation token). +- `RequestDetails`, `Request`, `Proxy`, and `Content` model the input JSON, supply request factories, and compute byte-size estimates. + +### HTTP Execution Agent +- **Files:** `src/Pulse/Core/Pulse.cs`, `src/Pulse/Core/PulseHttpClientFactory.cs`, `src/Pulse/Core/IPulseMonitor.cs` +- `Pulse.RunAsync` builds an `HttpClient` (proxy-aware via `PulseHttpClientFactory`), instantiates a monitor, and dispatches multiple tasks gated by a `SemaphoreSlim`. +- `IPulseMonitor.RequestExecutionContext` prepares `HttpRequestMessage` instances, executes them with streamed responses, tracks concurrency, and normalizes results into `Response`. +- Proxy handling honors bypass flags, credentials, and optional SSL certificate suppression (`Helper.ConfigureSslHandling` extension). + +### Monitoring Agents +- **Files:** `src/Pulse/Core/PulseMonitor.cs`, `src/Pulse/Core/VerbosePulseMonitor.cs`, `src/Pulse/Core/PaddedULong.cs` +- `PulseMonitor` provides dashboard-style aggregation: concurrent stacks store `Response` data while padded counters avoid false sharing when sampling metrics. +- `VerbosePulseMonitor` favors per-request logging (send/receive) with success tracking when verbose mode or single-shot execution is selected. +- Both implementations ultimately flush results through `ClearAndReturn`, returning a `PulseResult` with success rates and total duration. + +### Result Aggregation & Export Agents +- **Files:** `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/Exporter.cs`, `src/Pulse/Core/Response.cs`, `src/Pulse/Core/RawFailure.cs` +- `PulseSummary` deduplicates responses (`ResponseComparer`), tallies status buckets, computes latency/size statistics (with IQR filtering and SIMD acceleration), and orchestrates exports. +- `Exporter` emits either prettified HTML dashboards or raw JSON/HTML blobs, formats headers into tables, and can purge previous result folders. +- `RawFailure` offers a compact representation of unsuccessful HTTP interactions for raw exports. +- Throughput, ETA, and success-rate colorization rely on utilities in `Helper.cs`. + +### Error Handling & Diagnostic Agent +- **Files:** `src/Pulse/Core/ExceptionHandler.cs`, `src/Pulse/Configuration/StrippedException.cs`, `src/Pulse/Core/Helper.cs` +- `GlobalExceptionHandler` cleans console output on cancellation or unhandled exceptions and reports sanitized details. +- `StrippedException` recursively flattens exception metadata (type, message, optional detail, inner exceptions) while tracking default/no-error states. +- `Helper` exposes color heuristics, SSL configuration helpers, and diagnostic printers ensuring consistent console formatting. + +### Release & Version Agent +- **Files:** `src/Pulse/Core/ReleaseInfo.cs`, `src/Pulse/Core/Commands.cs` +- Maintains the CLI semantic version (`Commands.VERSION`) and validates it against assembly metadata (reinforced by unit tests). +- Parses GitHub release payloads (`ReleaseInfo`) to notify users about upgrades. + +### Shared Infrastructure Agent +- **Files:** `src/Pulse/GlobalUsings.cs`, `.editorconfig`, `Readme.md`, `History.md`, `Changelog.md` +- `GlobalUsings` centralizes PrettyConsole and `MethodImplOptions` imports for consistent styling. +- Repository documentation outlines usage patterns, options, and legal disclaimers (mirrored by `Commands.TermsOfUse`). +- Formatting guidelines and tooling preferences are driven by `.editorconfig`. + +## Testing Agents +- **Project:** `tests/Pulse.Tests.Unit` +- `ExporterTests.cs` validates clearing behaviors, file naming, header serialization, JSON formatting, HTML generation, and exception rendering across raw and HTML exports. +- `HelperTests.cs` asserts color-selection logic for status codes and success percentages. +- `HttpClientFactoryTests.cs` (and related fixtures) ensure handler composition respects proxy settings and SSL overrides. +- `ParametersTests.cs` document expected defaults for request counts, connections, and export toggles. +- `PulseMonitorTests.cs` exercise timeout handling via `RequestExecutionContext`. +- `StrippedExceptionTests.cs` confirm serialization contracts and null handling. +- `SummaryTests.cs` cover statistical reductions, outlier trimming, and SIMD-friendly averaging. +- `VersionTests.cs` keep `Commands.VERSION` synchronized with assembly metadata. + +## Data Flow Snapshot +1. User invokes the CLI; `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. +2. `Pulse.RunAsync` creates proxy-aware HTTP plumbing, chooses a monitor (verbose or dashboard), and schedules the requested workload with semaphore-throttled concurrency. +3. `RequestExecutionContext` issues HTTP requests, captures responses or exceptions, and records latency and concurrency metrics inside `Response`. +4. Monitors update live console feedback and accumulate results before handing off a `PulseResult`. +5. `PulseSummary` prints aggregated metrics, deduplicates responses, and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder. +6. `GlobalExceptionHandler` guarantees graceful shutdown, while optional commands (`get-sample`, `get-schema`, `update`, `terms-of-use`) reuse serialization agents for auxiliary workflows. + +## External Dependencies +- **ConsoleAppFramework** (CLI host) and **PrettyConsole** (colored terminal output). +- **Sharpify** utilities (string helpers) and `System.Text.Json` source generators for (de)serialization. +- Unit tests rely on **xUnit**, plus `Bogus` for synthetic file data within exporter tests. + +## Operational Notes +- Terms of use explicitly shift legal responsibility to operators; they are surfaced both in CLI output and repository docs. +- Export routines overwrite prior artifacts by design (`Exporter.ClearFiles`) before writing new responses. +- Vectorized statistics (`Vector512` / `Vector`) require modern hardware support but fall back to scalar logic when unavailable. +- The repository retains build artifacts under `bin/` and `obj/` across both the main app and test project; these were intentionally excluded from this summary. From 185680c4835cc253f0c25d3a92ceb109209603a0 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 3 Nov 2025 20:30:24 +0200 Subject: [PATCH 002/105] Updated unit tests to MTP and increased coverage --- tests/Pulse.Tests.Unit/Assembly.cs | 1 + tests/Pulse.Tests.Unit/AssemblyInfo.cs | 1 - tests/Pulse.Tests.Unit/ExporterTests.cs | 43 ++-- .../HttpClientFactoryTests.cs | 4 +- tests/Pulse.Tests.Unit/ParametersTests.cs | 21 +- .../Pulse.Tests.Unit/Pulse.Tests.Unit.csproj | 32 +-- .../Pulse.Tests.Unit/ResponseComparerTests.cs | 54 +++++ .../SencCommandParsingTests.cs | 184 ------------------ tests/Pulse.Tests.Unit/SummaryTests.cs | 41 ++++ tests/Pulse.Tests.Unit/xunit.runner.json | 9 + 10 files changed, 147 insertions(+), 243 deletions(-) create mode 100644 tests/Pulse.Tests.Unit/Assembly.cs delete mode 100644 tests/Pulse.Tests.Unit/AssemblyInfo.cs create mode 100644 tests/Pulse.Tests.Unit/ResponseComparerTests.cs delete mode 100644 tests/Pulse.Tests.Unit/SencCommandParsingTests.cs create mode 100644 tests/Pulse.Tests.Unit/xunit.runner.json diff --git a/tests/Pulse.Tests.Unit/Assembly.cs b/tests/Pulse.Tests.Unit/Assembly.cs new file mode 100644 index 0000000..6946172 --- /dev/null +++ b/tests/Pulse.Tests.Unit/Assembly.cs @@ -0,0 +1 @@ +[assembly: CaptureConsole] \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/AssemblyInfo.cs b/tests/Pulse.Tests.Unit/AssemblyInfo.cs deleted file mode 100644 index b0b47aa..0000000 --- a/tests/Pulse.Tests.Unit/AssemblyInfo.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/ExporterTests.cs b/tests/Pulse.Tests.Unit/ExporterTests.cs index 341d068..9c92427 100644 --- a/tests/Pulse.Tests.Unit/ExporterTests.cs +++ b/tests/Pulse.Tests.Unit/ExporterTests.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Pulse.Configuration; - using Pulse.Core; namespace Pulse.Tests.Unit; @@ -89,11 +88,11 @@ public async Task Exporter_Raw_NotSuccess_ContainsAllHeadersInJson() { // Act var expectedFileName = $"response-1337-status-code-502.json"; - await Exporter.ExportRawAsync(response, string.Empty, false); + await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); Assert.True(File.Exists(expectedFileName)); - var fileContent = await File.ReadAllTextAsync(expectedFileName); + var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); // Assert Assert.Contains("502", fileContent); @@ -125,11 +124,11 @@ public async Task Exporter_Raw_Success_ContainsOnlyContent() { // Act var expectedFileName = $"response-1337-status-code-200.html"; - await Exporter.ExportRawAsync(response, string.Empty, false); + await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); Assert.True(File.Exists(expectedFileName)); - var fileContent = await File.ReadAllTextAsync(expectedFileName); + var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedContent, fileContent); @@ -155,11 +154,11 @@ public async Task Exporter_Raw_NotSuccess_ButHasContent_ContainsOnlyContent() { // Act var expectedFileName = $"response-1337-status-code-502.html"; - await Exporter.ExportRawAsync(response, string.Empty, false); + await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); Assert.True(File.Exists(expectedFileName)); - var fileContent = await File.ReadAllTextAsync(expectedFileName); + var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedContent, fileContent); @@ -186,7 +185,7 @@ public async Task Exporter_ExportHtmlAsync_CorrectFileName() { }; // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); @@ -221,12 +220,12 @@ public async Task Exporter_ExportHtmlAsync_ContainsAllHeaders() { }; // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); foreach (var header in headers) { Assert.Contains(header.Key, fileContent); @@ -258,12 +257,12 @@ public async Task Exporter_ExportHtmlAsync_WithoutException_HasContent() { }; // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.Contains(expectedContent, fileContent); } finally { dirInfo.Delete(true); @@ -289,12 +288,12 @@ public async Task Exporter_ExportHtmlAsync_RawHtml() { }; // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName); + await Exporter.ExportRawAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.Equal(expectedContent, fileContent); } finally { dirInfo.Delete(true); @@ -324,12 +323,12 @@ public async Task Exporter_ExportHtmlAsync_RawJson() { }; // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName); + await Exporter.ExportRawAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.Equal(expectedContent, fileContent); Assert.DoesNotContain(Environment.NewLine, fileContent); } finally { @@ -360,12 +359,12 @@ public async Task Exporter_ExportHtmlAsync_RawJson_Formatted() { }; // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, true); + await Exporter.ExportRawAsync(response, dirInfo.FullName, true, TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.Contains(Environment.NewLine, fileContent); } finally { dirInfo.Delete(true); @@ -391,12 +390,12 @@ public async Task Exporter_ExportHtmlAsync_RawJson_Exception() { }; // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, true); + await Exporter.ExportRawAsync(response, dirInfo.FullName, true, TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.Contains("test", fileContent); Assert.Contains(Environment.NewLine, fileContent); } finally { @@ -424,12 +423,12 @@ public async Task Exporter_ExportHtmlAsync_WithException_HasExceptionAndNoConten }; // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); // Assert var file = dirInfo.GetFiles(); Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); Assert.DoesNotContain("Hello World", fileContent); Assert.Contains(exception.Message, fileContent); } finally { diff --git a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs index 785ba1c..45c1aca 100644 --- a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs +++ b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs @@ -13,7 +13,7 @@ public void HttpClientFactory_DefaultTimeout_IsInfinite() { var proxy = new Proxy(); // Act - using var httpClient = PulseHttpClientFactory.Create(proxy, ParametersBase.DefaultTimeoutInMs); + using var httpClient = PulseHttpClientFactory.Create(proxy, -1); // Assert Assert.Equal(Timeout.InfiniteTimeSpan, httpClient.Timeout); @@ -25,7 +25,7 @@ public void HttpClientFactory_WithoutProxy_ReturnsHttpClient() { var proxy = new Proxy(); // Act - using var httpClient = PulseHttpClientFactory.Create(proxy, ParametersBase.DefaultTimeoutInMs); + using var httpClient = PulseHttpClientFactory.Create(proxy, -1); // Assert Assert.NotNull(httpClient); diff --git a/tests/Pulse.Tests.Unit/ParametersTests.cs b/tests/Pulse.Tests.Unit/ParametersTests.cs index 15bad10..8f9d953 100644 --- a/tests/Pulse.Tests.Unit/ParametersTests.cs +++ b/tests/Pulse.Tests.Unit/ParametersTests.cs @@ -10,26 +10,7 @@ public void ParametersBase_Default() { // Assert Assert.Equal(1, @params.Requests); - Assert.Equal(ExecutionMode.Parallel, @params.ExecutionMode); - Assert.Equal(1, @params.MaxConnections); - Assert.False(@params.MaxConnectionsModified); - Assert.False(@params.FormatJson); - Assert.False(@params.UseFullEquality); - Assert.True(@params.Export); - Assert.False(@params.NoOp); - Assert.False(@params.Verbose); - } - - [Fact] - public void Parameters_FromBase_KeepsAllValues() { - // Arrange - var @params = new Parameters(new ParametersBase(), CancellationToken.None); - - // Assert - Assert.Equal(1, @params.Requests); - Assert.Equal(ExecutionMode.Parallel, @params.ExecutionMode); - Assert.Equal(1, @params.MaxConnections); - Assert.False(@params.MaxConnectionsModified); + Assert.Equal(1, @params.Connections); Assert.False(@params.FormatJson); Assert.False(@params.UseFullEquality); Assert.True(@params.Export); diff --git a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj b/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj index ba2a042..247d9ef 100644 --- a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj +++ b/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj @@ -1,34 +1,38 @@ - net9.0 enable enable - - false - true + Exe + Pulse.Tests.Unit + net9.0 + true + true - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + - + + + + \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/ResponseComparerTests.cs b/tests/Pulse.Tests.Unit/ResponseComparerTests.cs new file mode 100644 index 0000000..257d65e --- /dev/null +++ b/tests/Pulse.Tests.Unit/ResponseComparerTests.cs @@ -0,0 +1,54 @@ +using System.Net; + +using Pulse.Configuration; +using Pulse.Core; + +namespace Pulse.Tests.Unit; + +public class ResponseComparerTests { + [Fact] + public void Equals_NotUsingFullEquality_ComparesByContentLength() { + // Arrange + var parameters = new Parameters(new ParametersBase { UseFullEquality = false }, CancellationToken.None); + var comparer = new ResponseComparer(parameters); + var original = CreateResponse(1, HttpStatusCode.OK, "foo"); + var candidate = original with { + Id = 2, + Content = "bar", + ContentLength = 3 + }; + + // Act + Assert + Assert.True(comparer.Equals(original, candidate)); + Assert.Equal(comparer.GetHashCode(original), comparer.GetHashCode(candidate)); + } + + [Fact] + public void Equals_NotUsingFullEquality_IdentifiesDifferentLengths() { + // Arrange + var parameters = new Parameters(new ParametersBase { UseFullEquality = false }, CancellationToken.None); + var comparer = new ResponseComparer(parameters); + var original = CreateResponse(1, HttpStatusCode.OK, "foo"); + var different = original with { + Id = 2, + Content = "foobar", + ContentLength = 6 + }; + + // Act + Assert + Assert.False(comparer.Equals(original, different)); + } + + private static Response CreateResponse(int id, HttpStatusCode statusCode, string content) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = Array.Empty>>(), + Content = content, + ContentLength = content.Length, + Latency = TimeSpan.FromMilliseconds(10), + Exception = StrippedException.Default, + CurrentConcurrentConnections = 1 + }; + } +} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs b/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs deleted file mode 100644 index 30cbea8..0000000 --- a/tests/Pulse.Tests.Unit/SencCommandParsingTests.cs +++ /dev/null @@ -1,184 +0,0 @@ -using Pulse.Core; - -using Sharpify.CommandLineInterface; - -namespace Pulse.Tests.Unit; - -public class SendCommandParsingTests { - [Fact] - public void Arguments_Flag_NoOp() { - // Arrange - var args = Parser.ParseArguments("pulse --noop")!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.True(@params.NoOp); - } - - [Theory] - [InlineData("Pulse -v", -1)] // default - [InlineData("Pulse -v -t -1", -1)] // set but default - [InlineData("Pulse --verbose -t 30000", 30000)] // custom - [InlineData("Pulse --verbose --timeout 30000", 30000)] // custom - public void Arguments_Timeout(string arguments, int expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.TimeoutInMs); - } - - [Theory] - [InlineData("Pulse --delay 50", 0)] // default - [InlineData("Pulse --delay -9", 0)] // default - [InlineData("Pulse -m Sequential", 0)] // not set but default - [InlineData("Pulse -m Sequential --delay 50", 50)] // set - [InlineData("Pulse -m Sequential --delay -9", 0)] // set but default since negative - public void Arguments_Delay(string arguments, int expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.DelayInMs); - } - - [Theory] - [InlineData("Pulse -v")] - [InlineData("Pulse --verbose")] - public void Arguments_Flag_Verbose(string arguments) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.True(@params.Verbose); - } - - [Theory] - [InlineData("Pulse -v")] - [InlineData("Pulse --verbose")] - public void Arguments_BatchSize_NotModified(string arguments) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.False(@params.MaxConnectionsModified); - Assert.Equal(1, @params.MaxConnections); - } - - [Fact] - public void Arguments_MaxConnections_Modified() { - // Arrange - var args = Parser.ParseArguments("Pulse -c 5")!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.True(@params.MaxConnectionsModified); - Assert.Equal(5, @params.MaxConnections); - } - - [Theory] - [InlineData("Pulse -m sequential")] - [InlineData("Pulse --mode sequential")] - public void Arguments_ModeSequential(string arguments) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(Configuration.ExecutionMode.Sequential, @params.ExecutionMode); - } - - [Theory] - [InlineData("Pulse -m parallel")] - [InlineData("Pulse --mode parallel")] - [InlineData("Pulse")] - public void Arguments_ModeParallel(string arguments) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(Configuration.ExecutionMode.Parallel, @params.ExecutionMode); - } - - [Theory] - [InlineData("Pulse", 1)] - [InlineData("Pulse -n 1", 1)] - [InlineData("Pulse --number 1", 1)] - [InlineData("Pulse -n 0", 1)] // Zero is not allowed - default is used - [InlineData("Pulse -n -1", 1)] // Negative number is not allowed - default is used - [InlineData("Pulse -n 50", 50)] - [InlineData("Pulse --number 50", 50)] - public void Arguments_NumberOfRequests(string arguments, int expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.Requests); - } - - [Theory] - [InlineData("Pulse", true)] - [InlineData("Pulse --no-export", false)] - public void Arguments_NoExport(string arguments, bool expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.Export); - } - - [Theory] - [InlineData("Pulse", false)] - [InlineData("Pulse --json", true)] - public void Arguments_FormatJson(string arguments, bool expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.FormatJson); - } - - [Theory] - [InlineData("Pulse", false)] - [InlineData("Pulse -f", true)] - public void Arguments_UseFullEquality(string arguments, bool expected) { - // Arrange - var args = Parser.ParseArguments(arguments)!; - - // Act - var @params = SendCommand.ParseParametersArgs(args); - - // Assert - Assert.Equal(expected, @params.UseFullEquality); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/SummaryTests.cs b/tests/Pulse.Tests.Unit/SummaryTests.cs index f7baaeb..cd00be6 100644 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ b/tests/Pulse.Tests.Unit/SummaryTests.cs @@ -1,3 +1,7 @@ +using System.Collections.Concurrent; +using System.Net; + +using Pulse.Configuration; using Pulse.Core; namespace Pulse.Tests.Unit; @@ -51,4 +55,41 @@ public SummaryTestData() { Add(Enumerable.Range(1, 1000).Select(x => (double)x).Union([-1000.0, 2000.0]).ToArray(), true, 1.0, 1000.0, 500.5, 2); } } + + [Fact] + public void Summarize_DeduplicatesResponses_WhenExportEnabled() { + // Arrange + var parameters = new Parameters(new ParametersBase { Export = true }, CancellationToken.None); + var responses = new[] { + CreateResponse(1, HttpStatusCode.OK, "alpha"), + CreateResponse(2, HttpStatusCode.OK, "beta"), + CreateResponse(3, HttpStatusCode.OK, "beta") // Same length as response 2 -> should deduplicate + }; + var stack = new ConcurrentStack(responses); + var pulseResult = new PulseResult { + Results = stack, + TotalDuration = TimeSpan.FromSeconds(1), + SuccessRate = 100 + }; + + // Act + var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, pulseResult, requestSizeInBytes: 16); + + // Assert + Assert.True(exportRequired); + Assert.Equal(2, uniqueRequests.Count); + } + + private static Response CreateResponse(int id, HttpStatusCode statusCode, string content) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = Array.Empty>>(), + Content = content, + ContentLength = content.Length, + Latency = TimeSpan.FromMilliseconds(10), + Exception = StrippedException.Default, + CurrentConcurrentConnections = 1 + }; + } } \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/xunit.runner.json b/tests/Pulse.Tests.Unit/xunit.runner.json new file mode 100644 index 0000000..07481e5 --- /dev/null +++ b/tests/Pulse.Tests.Unit/xunit.runner.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true, + "internalDiagnosticMessages": true, + "preEnumerateTheories": true, + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "showLiveOutput": false +} \ No newline at end of file From 4a3ffbc1462dacd10f0df822a65ee8c646e5e16c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 3 Nov 2025 20:31:23 +0200 Subject: [PATCH 003/105] updated workflow --- .github/workflows/unit-tests.yaml | 46 +++++-------------------------- 1 file changed, 7 insertions(+), 39 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 99ffb2b..a11edb4 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -5,45 +5,13 @@ on: workflow_dispatch: jobs: - test-pulse: - runs-on: ${{ matrix.os }} + unit-tests-matrix: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Debug, Release] - - env: - # Define the path to project and test project - PROJECT: src/Pulse/Pulse.csproj - TEST_PROJECT: tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj - - steps: - # 1. Checkout the repository code - - name: Checkout Repository - uses: actions/checkout@v4 - - # 2. Cache NuGet packages - - name: Cache NuGet Packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - # 3. Setup .NET - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - # 4. Clean - - name: Clean - run: | - dotnet clean ${{ env.PROJECT }} -c ${{ matrix.configuration }} - dotnet clean ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - # 5. Run Unit Tests - - name: Run Unit Tests - run: dotnet test ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} \ No newline at end of file + platform: [ubuntu-latest, windows-latest, macos-latest] + uses: dusrdev/actions/.github/workflows/reusable-dotnet-test-mtp.yaml@main + with: + platform: ${{ matrix.platform }} + dotnet-version: 9.0.x + test-project-path: tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj \ No newline at end of file From 0c76e1f29c4c986a0788a854d712fee8b3787baf Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 3 Nov 2025 20:32:24 +0200 Subject: [PATCH 004/105] Updated dependencies and refined all codebase --- src/Pulse/Configuration/DefaultJsonContext.cs | 75 ++- src/Pulse/Configuration/ExecutionMode.cs | 15 - src/Pulse/Configuration/InputJsonContext.cs | 59 +- src/Pulse/Configuration/Parameters.cs | 154 ++--- src/Pulse/Configuration/StrippedException.cs | 126 ++-- src/Pulse/Core/Commands.cs | 221 +++++++ src/Pulse/Core/ExceptionHandler.cs | 33 ++ src/Pulse/Core/Exporter.cs | 264 +++++---- src/Pulse/Core/Helper.cs | 74 ++- src/Pulse/Core/IPulseMonitor.cs | 216 ++++--- src/Pulse/Core/PaddedULong.cs | 2 +- src/Pulse/Core/Pulse.cs | 121 +--- src/Pulse/Core/PulseHttpClientFactory.cs | 75 +-- src/Pulse/Core/PulseMonitor.cs | 260 ++++----- src/Pulse/Core/PulseResult.cs | 26 +- src/Pulse/Core/PulseSummary.cs | 542 ++++++++---------- src/Pulse/Core/RawFailure.cs | 22 +- src/Pulse/Core/ReleaseInfo.cs | 10 +- src/Pulse/Core/RequestDetails.cs | 266 +++++---- src/Pulse/Core/Response.cs | 201 +++---- src/Pulse/Core/SendCommand.cs | 269 --------- src/Pulse/Core/VerbosePulseMonitor.cs | 136 ++--- src/Pulse/GlobalUsings.cs | 6 + src/Pulse/Program.cs | 59 +- src/Pulse/Pulse.csproj | 17 +- tests/Pulse.Tests.Unit/VersionTests.cs | 4 +- 26 files changed, 1496 insertions(+), 1757 deletions(-) delete mode 100644 src/Pulse/Configuration/ExecutionMode.cs create mode 100644 src/Pulse/Core/Commands.cs create mode 100644 src/Pulse/Core/ExceptionHandler.cs delete mode 100644 src/Pulse/Core/SendCommand.cs create mode 100644 src/Pulse/GlobalUsings.cs diff --git a/src/Pulse/Configuration/DefaultJsonContext.cs b/src/Pulse/Configuration/DefaultJsonContext.cs index c727600..6e3f208 100644 --- a/src/Pulse/Configuration/DefaultJsonContext.cs +++ b/src/Pulse/Configuration/DefaultJsonContext.cs @@ -3,54 +3,49 @@ using Pulse.Core; -using Sharpify; - namespace Pulse.Configuration; [JsonSourceGenerationOptions(AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, - UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - IncludeFields = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString, - WriteIndented = true, - UseStringEnumConverter = true)] + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, + UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + WriteIndented = true, + UseStringEnumConverter = true)] [JsonSerializable(typeof(Dictionary>))] [JsonSerializable(typeof(RawFailure))] [JsonSerializable(typeof(StrippedException))] [JsonSerializable(typeof(ReleaseInfo))] public partial class DefaultJsonContext : JsonSerializerContext { - /// - /// Deserializes the version from the release info JSON - /// - /// - /// - public static Result DeserializeVersion(ReadOnlySpan releaseInfoJson) { - var releaseInfo = JsonSerializer.Deserialize(releaseInfoJson, Default.ReleaseInfo); - if (releaseInfo is null or { Version: null } || !Version.TryParse(releaseInfo.Version, out Version? remoteVersion)) { - return Result.Fail("Failed to retrieve version for remote"); - } - return Result.Ok(remoteVersion); - } - - /// - /// Serializes an exception to a string. - /// - /// - /// - public static string SerializeException(Exception e) => SerializeException(Configuration.StrippedException.FromException(e)); + /// + /// Deserializes the version from the release info JSON + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryDeserializeVersion(ReadOnlySpan releaseInfoJson, out Version? version) { + version = null; + var releaseInfo = JsonSerializer.Deserialize(releaseInfoJson, Default.ReleaseInfo); + if (releaseInfo is null or { Version: null } || !Version.TryParse(releaseInfo.Version, out version)) { + return false; + } + return true; + } - /// - /// Serializes a stripped exception to a string. - /// - /// - /// - public static string SerializeException(StrippedException e) => JsonSerializer.Serialize(e, Default.StrippedException); + /// + /// Serializes a stripped exception to a string. + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string SerializeException(StrippedException e) => JsonSerializer.Serialize(e, Default.StrippedException); - /// - /// Serialize to a string. - /// - /// - public static string Serialize(RawFailure failure) => JsonSerializer.Serialize(failure, Default.RawFailure); + /// + /// Serialize to a string. + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string Serialize(RawFailure failure) => JsonSerializer.Serialize(failure, Default.RawFailure); } \ No newline at end of file diff --git a/src/Pulse/Configuration/ExecutionMode.cs b/src/Pulse/Configuration/ExecutionMode.cs deleted file mode 100644 index 71c6700..0000000 --- a/src/Pulse/Configuration/ExecutionMode.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Pulse.Configuration; - -/// -/// Execution mode -/// -public enum ExecutionMode { - /// - /// Execute requests sequentially - /// - Sequential, - /// - /// Execute requests in parallel - /// - Parallel -} \ No newline at end of file diff --git a/src/Pulse/Configuration/InputJsonContext.cs b/src/Pulse/Configuration/InputJsonContext.cs index 0091692..599e7dd 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -3,38 +3,43 @@ using Pulse.Core; -using Sharpify; - namespace Pulse.Configuration; [JsonSourceGenerationOptions(AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - ReadCommentHandling = JsonCommentHandling.Skip, - UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, - PropertyNameCaseInsensitive = true, - UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, - IncludeFields = true, - NumberHandling = JsonNumberHandling.AllowReadingFromString, - WriteIndented = true, - UseStringEnumConverter = true)] + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + ReadCommentHandling = JsonCommentHandling.Skip, + UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + WriteIndented = true, + UseStringEnumConverter = true)] [JsonSerializable(typeof(RequestDetails))] [JsonSerializable(typeof(JsonElement))] public partial class InputJsonContext : JsonSerializerContext { - /// - /// Try to get request details from file - /// - /// - /// - public static Result TryGetRequestDetailsFromFile(string path) { - if (!File.Exists(path)) { - return Result.Fail($"{path} - does not exist.", new RequestDetails()); - } + /// + /// Try to get request details from file + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool TryGetRequestDetailsFromFile(string path, out RequestDetails? details) { + if (!File.Exists(path)) { + details = null; + return false; + } + + var json = File.ReadAllText(path); + var rd = JsonSerializer.Deserialize(json, Default.RequestDetails); - var json = File.ReadAllText(path); - var rd = JsonSerializer.Deserialize(json, Default.RequestDetails); - if (rd is null) { - return Result.Fail($"{path} - contained empty or invalid JSON.", new RequestDetails()); - } - return Result.Ok(rd); - } + if (rd is null) { + details = null; + return false; + } else { + details = rd; + return true; + } + } } \ No newline at end of file diff --git a/src/Pulse/Configuration/Parameters.cs b/src/Pulse/Configuration/Parameters.cs index ab37d89..62405f4 100644 --- a/src/Pulse/Configuration/Parameters.cs +++ b/src/Pulse/Configuration/Parameters.cs @@ -4,102 +4,72 @@ namespace Pulse.Configuration; /// Execution parameters /// public record ParametersBase { - /// - /// Default number of requests - /// - public const int DefaultNumberOfRequests = 1; - - /// - /// Default maximum active connections - /// - public const int DefaultMaxConnections = 1; - - /// - /// Default timeout in milliseconds (infinity) - /// - public const int DefaultTimeoutInMs = -1; - - /// - /// Default execution mode - /// - public const ExecutionMode DefaultExecutionMode = ExecutionMode.Parallel; - - /// - /// Sets the number of requests (default = 100) - /// - public int Requests { get; set; } = DefaultNumberOfRequests; - - /// - /// Sets the timeout in milliseconds - /// - public int TimeoutInMs { get; set; } = DefaultTimeoutInMs; - - /// - /// The delay between requests in milliseconds (Only used when is ) - /// - public int DelayInMs { get; set; } - - /// - /// Sets the execution mode (default = ) - /// - public ExecutionMode ExecutionMode { get; init; } = DefaultExecutionMode; - - /// - /// Sets the maximum connections (default = ) - /// - public int MaxConnections { get; init; } = DefaultMaxConnections; - - /// - /// Indicating whether the max connections parameter was modified - /// - public bool MaxConnectionsModified { get; init; } - - /// - /// Attempt to format response content as JSON - /// - public bool FormatJson { get; init; } - - /// - /// Indicating whether to export raw results (without wrapping in custom html) - /// - public bool ExportRaw { get; init; } - - /// - /// Indicating whether to export results - /// - public bool Export { get; init; } = true; - - /// - /// Check full equality for response content - /// - public bool UseFullEquality { get; init; } - - /// - /// Display configuration and exit - /// - public bool NoOp { get; init; } - - /// - /// Display verbose output (adds more metrics) - /// - public bool Verbose { get; init; } - - /// - /// Output folder - /// - public string OutputFolder { get; init; } = "results"; + /// + /// Sets the number of requests (default = 100) + /// + public int Requests { get; set; } = 1; + + /// + /// Sets the timeout in milliseconds + /// + public int TimeoutInMs { get; set; } = -1; + + /// + /// The delay between requests in milliseconds + /// + public int DelayInMs { get; set; } = 0; + + /// + /// Sets the maximum connections + /// + public int Connections { get; init; } = 1; + + /// + /// Attempt to format response content as JSON + /// + public bool FormatJson { get; init; } + + /// + /// Indicating whether to export raw results (without wrapping in custom html) + /// + public bool ExportRaw { get; init; } + + /// + /// Indicating whether to export results + /// + public bool Export { get; init; } = true; + + /// + /// Check full equality for response content + /// + public bool UseFullEquality { get; init; } + + /// + /// Display configuration and exit + /// + public bool NoOp { get; init; } + + /// + /// Display verbose output (adds more metrics) + /// + public bool Verbose { get; init; } + + /// + /// Output folder + /// + public string OutputFolder { get; init; } = "results"; } /// /// Execution parameters /// public sealed record Parameters : ParametersBase { - /// - /// Application-wide cancellation token - /// - public readonly CancellationToken CancellationToken; - - public Parameters(ParametersBase @base, CancellationToken cancellationToken) : base(@base) { - CancellationToken = cancellationToken; - } + /// + /// Application-wide cancellation token + /// + public readonly CancellationToken CancellationToken; + + public Parameters(ParametersBase @base, CancellationToken cancellationToken) : base(@base) { + CancellationToken = cancellationToken; + } } \ No newline at end of file diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Configuration/StrippedException.cs index e72e095..b2a8938 100644 --- a/src/Pulse/Configuration/StrippedException.cs +++ b/src/Pulse/Configuration/StrippedException.cs @@ -8,76 +8,76 @@ namespace Pulse.Configuration; /// An exception only containing the type, message and stack trace /// public sealed record StrippedException { - public static readonly StrippedException Default = new(); + public static readonly StrippedException Default = new(); - /// - /// Type of the exception - /// - public readonly string Type; + /// + /// Type of the exception + /// + public readonly string Type; - /// - /// Message of the exception - /// - public readonly string Message; + /// + /// Message of the exception + /// + public readonly string Message; - /// - /// Detail of the exception (if any) - /// - public readonly string? Detail; + /// + /// Detail of the exception (if any) + /// + public readonly string? Detail; - /// - /// Inner exception (if any) - /// - public readonly StrippedException? InnerException; + /// + /// Inner exception (if any) + /// + public readonly StrippedException? InnerException; - /// - /// Indicating whether the exception is the default exception (i.e. no exception) - /// - [JsonIgnore] - public readonly bool IsDefault; + /// + /// Indicating whether the exception is the default exception (i.e. no exception) + /// + [JsonIgnore] + public readonly bool IsDefault; - /// - /// Creates a stripped exception from an exception or returns the default - /// - /// - /// - public static StrippedException FromException(Exception? exception) { - if (exception is null) { - return Default; - } - return new StrippedException(exception); - } + /// + /// Creates a stripped exception from an exception or returns the default + /// + /// + /// + public static StrippedException FromException(Exception? exception) { + if (exception is null) { + return Default; + } + return new StrippedException(exception); + } - /// - /// Creates a stripped exception from an exception - /// - /// - private StrippedException(Exception exception) { - Type = exception.GetType().Name; - Message = exception.Message; - Detail = Helper.AddExceptionDetail(exception); - if (exception.InnerException is not null) { - InnerException = FromException(exception.InnerException); - } - IsDefault = false; - } + /// + /// Creates a stripped exception from an exception + /// + /// + private StrippedException(Exception exception) { + Type = exception.GetType().Name; + Message = exception.Message; + Detail = Helper.AddExceptionDetail(exception); + if (exception.InnerException is not null) { + InnerException = FromException(exception.InnerException); + } + IsDefault = false; + } - /// - /// Creates a stripped exception from a type, message and stack trace - /// - /// - /// - /// - public StrippedException(string type, string message) { - Type = type; - Message = message; - IsDefault = false; - } + /// + /// Creates a stripped exception from a type, message and stack trace + /// + /// + /// + /// + public StrippedException(string type, string message) { + Type = type; + Message = message; + IsDefault = false; + } - [JsonConstructor] - public StrippedException() { - Type = string.Empty; - Message = string.Empty; - IsDefault = true; - } + [JsonConstructor] + public StrippedException() { + Type = string.Empty; + Message = string.Empty; + IsDefault = true; + } } \ No newline at end of file diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs new file mode 100644 index 0000000..b6dfa50 --- /dev/null +++ b/src/Pulse/Core/Commands.cs @@ -0,0 +1,221 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Schema; + +using ConsoleAppFramework; + +using Pulse.Configuration; + +namespace Pulse.Core; + +/// +/// Commands +/// +public static class Commands { + public const string VERSION = "2.0.0.0"; + + /// + /// Pulse - A hyper fast general purpose HTTP request tester + /// + /// Path to .json request details file [use "get-sample" if you don't have one] + /// Try to format response content as JSON + /// Export raw results [without wrapping in custom HTML] + /// -f, Use full equality [slower] + /// Don't export results + /// -v, Display verbose output + /// Print selected configuration but don't run + /// -o, Output folder + /// -d, Delay in milliseconds between requests + /// -c, Maximum number of parallel requests + /// -u, Override the url of the request + /// -n, Number of total requests + /// -t, Timeout in milliseconds + /// + /// + public static async Task Root([Argument] string requestFile, + bool json, + bool raw, + bool fullEquality, + bool noExport, + bool verbose, + bool noOp, + string output = "results", + int delay = -1, + int? connections = null, + string? url = null, + [Range(1, int.MaxValue)] int number = 1, + int timeout = -1, + CancellationToken ct = default) { + connections ??= number; + + var parametersBase = new ParametersBase { + Requests = number, + TimeoutInMs = timeout, + DelayInMs = delay, + Connections = connections.Value, + FormatJson = json, + ExportRaw = raw, + UseFullEquality = fullEquality, + Export = !noExport, + NoOp = noOp, + Verbose = verbose, + OutputFolder = output + }; + + if (!InputJsonContext.TryGetRequestDetailsFromFile(Path.GetFullPath(requestFile), out var requestDetails)) { + WriteLine(OutputPipe.Error, $"Failed to retrieve and parse request file from {Yellow}{requestFile}"); + return 1; + } + ArgumentNullException.ThrowIfNull(requestDetails); + if (url is not null) { + requestDetails.Request.Url = url; + } + + var @params = new Parameters(parametersBase, ct); + + if (@params.NoOp) { + PrintConfiguration(@params, requestDetails); + return 0; + } + + WriteLine($"{Helper.GetMethodBasedColor(requestDetails.Request.Method.Method)}{requestDetails.Request.Method.Method}{Default} => {requestDetails.Request.Url}"); + await Pulse.RunAsync(@params, requestDetails); + return 0; + } + + /// + /// Checks whether there is a new version out on GitHub releases. + /// + /// + /// + public static async Task CheckForUpdates(CancellationToken ct = default) { + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("User-Agent", "C# App"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); + using var message = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/dusrdev/Pulse/releases/latest"); + using var response = await client.SendAsync(message, ct); + if (response.IsSuccessStatusCode) { + var json = await response.Content.ReadAsStringAsync(ct); + if (!DefaultJsonContext.TryDeserializeVersion(json, out var remoteVersion)) { + WriteLine(OutputPipe.Error, $"Failed to retrieve version from remote."); + return 1; + } + ArgumentNullException.ThrowIfNull(remoteVersion); + var currentVersion = Version.Parse(VERSION); + if (currentVersion < remoteVersion) { + WriteLine($"{Yellow}A new version of Pulse is available!"); + WriteLine($"Your version: {Yellow}{VERSION}"); + WriteLine($"Latest version: {Green}{remoteVersion}"); + NewLine(); + WriteLine($"Download from https://github.com/dusrdev/Pulse/releases/latest"); + } else { + WriteLine($"{Green}You are using the latest version of Pulse."); + } + return 0; + } else { + WriteLine(OutputPipe.Error, $"Failed to check for updates - server response was not success"); + return 1; + } + } + + /// + /// Print the terms of use. + /// + /// + public static int TermsOfUse() { + WriteLine( + $""" + By using this tool you agree to take full responsibility for the consequences of its use. + + Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's + responsibility to obey all applicable local, state and federal laws. + Developers assume no liability and are not responsible for any misuse or damage caused by this program. + """ + ); + return 0; + } + + /// + /// Generate a json schema file + /// + /// -d, Configures in which directory [will default to current] + /// + /// + public static async Task GetSchema(string? directory = null, CancellationToken ct = default) { + directory ??= Directory.GetCurrentDirectory(); + var path = Path.Join(directory, "schema.json"); + var options = new JsonSchemaExporterOptions { + TreatNullObliviousAsNonNullable = true, + }; + var schema = InputJsonContext.Default.RequestDetails.GetJsonSchemaAsNode(options).ToString(); + await File.WriteAllTextAsync(path, schema, ct); + WriteLine($"Schema generated at {Yellow}{path}"); + return 0; + } + + /// + /// Generate sample request file + /// + /// -d, Configures in which directory [will default to current] + /// + /// + public static async Task GetSample(string? directory = null, CancellationToken ct = default) { + directory ??= Directory.GetCurrentDirectory(); + var path = Path.Join(directory, "sample.json"); + var json = JsonSerializer.Serialize(new RequestDetails(), InputJsonContext.Default.RequestDetails); + await File.WriteAllTextAsync(path, json, ct); + WriteLine($"Sample request generated at {Yellow}{path}"); + return 0; + } + + /// + /// Prints the configuration + /// + /// + /// + internal static void PrintConfiguration(Parameters parameters, RequestDetails requestDetails) { + Color headerColor = Cyan; + Color property = DarkGray; + Color value = White; + + // Options + WriteLine($"{headerColor}Options:"); + WriteLine($"{property} Request count: {value}{parameters.Requests}"); + WriteLine($"{property} Timeout: {value}{parameters.TimeoutInMs}"); + WriteLine($"{property} Export Raw: {value}{parameters.ExportRaw}"); + WriteLine($"{property} Format JSON: {value}{parameters.FormatJson}"); + WriteLine($"{property} Export Full Equality: {value}{parameters.UseFullEquality}"); + WriteLine($"{property} Export: {value}{parameters.Export}"); + WriteLine($"{property} Verbose: {value}{parameters.Verbose}"); + WriteLine($"{property} Output Folder: {value}{parameters.OutputFolder}"); + + // Request + WriteLine($"{headerColor}Request:"); + WriteLine($"{property} URL: {value}{requestDetails.Request.Url}"); + WriteLine($"{property} Method: {value}{requestDetails.Request.Method}"); + WriteLine($"{Yellow} Headers:"); + if (requestDetails.Request.Headers.Count > 0) { + foreach (var header in requestDetails.Request.Headers) { + if (header.Value is null) { + continue; + } + WriteLine($"{property} {header.Key}: {value}{header.Value.Value}"); + } + } + if (requestDetails.Request.Content.Body.HasValue) { + WriteLine($"{Yellow} Content:"); + WriteLine($"{property} ContentType: {value}{requestDetails.Request.Content.GetContentType()}"); + WriteLine($"{property} Body: {value}{requestDetails.Request.Content.Body}"); + } else { + WriteLine($"{property} Content: {value}none"); + } + + // Proxy + WriteLine($"{headerColor}Proxy:"); + WriteLine($"{property} Bypass: {value}{requestDetails.Proxy.Bypass}"); + WriteLine($"{property} Host: {value}{requestDetails.Proxy.Host}"); + WriteLine($"{property} Username: {value}{requestDetails.Proxy.Username}"); + WriteLine($"{property} Password: {value}{requestDetails.Proxy.Password}"); + WriteLine($"{property} Ignore SSL: {value}{requestDetails.Proxy.IgnoreSSL}"); + } +} \ No newline at end of file diff --git a/src/Pulse/Core/ExceptionHandler.cs b/src/Pulse/Core/ExceptionHandler.cs new file mode 100644 index 0000000..9135e2b --- /dev/null +++ b/src/Pulse/Core/ExceptionHandler.cs @@ -0,0 +1,33 @@ +using ConsoleAppFramework; + +using Pulse.Configuration; + +namespace Pulse.Core; + +internal sealed class GlobalExceptionHandler(ConsoleAppFilter next) : ConsoleAppFilter(next) { + public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { + int startLine = GetCurrentLine(); + try { + await Next.InvokeAsync(context, cancellationToken); + } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { + ClearFrom(startLine); + WriteLine(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); + Environment.ExitCode = 1; + } catch (Exception e) { + ClearFrom(startLine); + WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at dusrdev@gmail.com and provide the following:"); + NewLine(OutputPipe.Error); + Helper.PrintException(StrippedException.FromException(e)); + Environment.ExitCode = 1; + } + + static void ClearFrom(int start) { + int lines = GetCurrentLine() - start + 1; + GoToLine(start); + ClearNextLines(lines, OutputPipe.Error); + GoToLine(start); + ClearNextLines(lines, OutputPipe.Out); + GoToLine(start); + } + } +} \ No newline at end of file diff --git a/src/Pulse/Core/Exporter.cs b/src/Pulse/Core/Exporter.cs index cb207b0..8664406 100644 --- a/src/Pulse/Core/Exporter.cs +++ b/src/Pulse/Core/Exporter.cs @@ -4,116 +4,113 @@ using Pulse.Configuration; -using Sharpify; - namespace Pulse.Core; public static class Exporter { - public static Task ExportResponseAsync(Response result, string path, Parameters parameters, CancellationToken token = default) { - if (token.IsCancellationRequested) { - return Task.CompletedTask; - } + private const string JsonExtension = "json"; + private const string HtmlExtension = "html"; + + public static Task ExportResponseAsync(Response result, string path, Parameters parameters, CancellationToken token = default) { + if (token.IsCancellationRequested) { + return Task.CompletedTask; + } - if (parameters.ExportRaw) { - return ExportRawAsync(result, path, parameters.FormatJson, token); - } else { - return ExportHtmlAsync(result, path, parameters.FormatJson, token); + if (parameters.ExportRaw) { + return ExportRawAsync(result, path, parameters.FormatJson, token); + } else { + return ExportHtmlAsync(result, path, parameters.FormatJson, token); + } } - } - internal static async Task ExportRawAsync(Response result, string path, bool formatJson = false, CancellationToken token = default) { - bool hasContent = result.Content.Length != 0; + internal static async Task ExportRawAsync(Response result, string path, bool formatJson = false, CancellationToken token = default) { + bool hasContent = result.Content.Length != 0; - HttpStatusCode statusCode = result.StatusCode; - string extension; - string content; + HttpStatusCode statusCode = result.StatusCode; + string extension; + string content; - if (!result.Exception.IsDefault) { - content = DefaultJsonContext.SerializeException(result.Exception); - extension = "json"; - } else { - if (result.StatusCode is not HttpStatusCode.OK && !hasContent) { - var failure = new RawFailure { - StatusCode = (int)result.StatusCode, - Headers = result.Headers.ToDictionary(), - Content = result.Content - }; - content = DefaultJsonContext.Serialize(failure); - extension = "json"; - } else if (formatJson) { - content = FormatJson(result.Content).Message; - extension = "json"; - } else { - content = result.Content; - extension = "html"; - } - } + if (!result.Exception.IsDefault) { + content = DefaultJsonContext.SerializeException(result.Exception); + extension = JsonExtension; + } else { + if (result.StatusCode is not HttpStatusCode.OK && !hasContent) { + var failure = new RawFailure { + StatusCode = (int)result.StatusCode, + Headers = result.Headers.ToDictionary(), + Content = result.Content + }; + content = DefaultJsonContext.Serialize(failure); + extension = JsonExtension; + } else if (formatJson) { + try { + using var doc = JsonDocument.Parse(result.Content); + var root = doc.RootElement; + var json = JsonSerializer.Serialize(root, InputJsonContext.Default.JsonElement); + content = json; + } catch { + content = result.Content; + } + extension = JsonExtension; + } else { + content = result.Content; + extension = HtmlExtension; + } + } - string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.{extension}"); + string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.{extension}"); - await File.WriteAllTextAsync(filename, content, token); - - static Result FormatJson(string content) { - try { - using var doc = JsonDocument.Parse(content); - var root = doc.RootElement; - var json = JsonSerializer.Serialize(root, InputJsonContext.Default.JsonElement); - return Result.Ok(json); - } catch (JsonException) { - return Result.Fail("Failed to format content as JSON"); - } + await File.WriteAllTextAsync(filename, content, token); } - } - internal static async Task ExportHtmlAsync(Response result, string path, bool formatJson = false, CancellationToken token = default) { - HttpStatusCode statusCode = result.StatusCode; - string frameTitle; - string content = string.IsNullOrWhiteSpace(result.Content) ? string.Empty : result.Content; - string status; + internal static async Task ExportHtmlAsync(Response result, string path, bool formatJson = false, CancellationToken token = default) { + HttpStatusCode statusCode = result.StatusCode; + string frameTitle; + string content = string.IsNullOrWhiteSpace(result.Content) ? string.Empty : result.Content; + string status; - if (result.Exception.IsDefault) { - status = $"{statusCode} ({(int)statusCode})"; - frameTitle = "Content:"; - if (formatJson) { - try { - using var doc = JsonDocument.Parse(content); - var root = doc.RootElement; - var json = JsonSerializer.Serialize(root, InputJsonContext.Default.JsonElement); - content = $"
{json}
"; - } catch (JsonException) { } // Ignore - Keep content as is - } - content = content.Replace('\'', '\"'); - } else { - status = "Exception (0)"; - frameTitle = "Exception:"; - content = $"
{DefaultJsonContext.SerializeException(result.Exception)}
"; - } + if (result.Exception.IsDefault) { + status = $"{statusCode} ({(int)statusCode})"; + frameTitle = "Content:"; + if (formatJson) { + try { + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement; + var json = JsonSerializer.Serialize(root, InputJsonContext.Default.JsonElement); + content = $"
{json}
"; + } catch (JsonException) { } // Ignore - Keep content as is + } + content = content.Replace('\'', '\"'); + } else { + status = "Exception (0)"; + frameTitle = "Exception:"; + content = $"
{DefaultJsonContext.SerializeException(result.Exception)}
"; + } - string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.html"); - string contentFrame = content == string.Empty ? -""" + string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.html"); + string contentFrame = content == string.Empty ? + """

Content: Empty...

""" -: -$$""" + : + $$"""

{{frameTitle}}

"""; - string headers = string.Empty; - if (result.Headers.Any()) { - headers = - $""" + string headers = string.Empty; + if (result.Headers.Any()) { + headers = + $"""
{ToHtmlTable(result.Headers)}
"""; - } - const string css = -""" + } + const string css = + """ /* Reset and Base Styles */ *, *::before, *::after { box-sizing: border-box; @@ -277,8 +274,8 @@ tbody td { } } """; - string body = -$$""" + string body = + $$""" Response: {{result.Id}} @@ -299,62 +296,59 @@ tbody td { """; - await File.WriteAllTextAsync(filename, body, token); - } + await File.WriteAllTextAsync(filename, body, token); + } - /// - /// Converts HttpResponseHeaders to an HTML table representation. - /// - /// The HttpResponseHeaders to convert. - /// A string containing the HTML table. - /// Thrown when headers is null. - internal static string ToHtmlTable(IEnumerable>> headers) { - StringBuilder sb = new(); + /// + /// Converts HttpResponseHeaders to an HTML table representation. + /// + /// The HttpResponseHeaders to convert. + /// A string containing the HTML table. + /// Thrown when headers is null. + internal static string ToHtmlTable(IEnumerable>> headers) { + StringBuilder sb = new(1024); - // Start the table and add some basic styling - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); + // Start the table and add some basic styling + sb.AppendLine("
HeaderValue
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); - foreach (var header in headers) { - string headerName = WebUtility.HtmlEncode(header.Key); - string headerValues = WebUtility.HtmlEncode(string.Join(", ", header.Value)); + foreach (var header in headers) { + string headerName = WebUtility.HtmlEncode(header.Key); + string headerValues = WebUtility.HtmlEncode(string.Join(", ", header.Value)); - sb.AppendLine(""); - sb.AppendLine($""); - sb.AppendLine($""); - sb.AppendLine(""); - } + sb.AppendLine(""); + sb.AppendLine($""); + sb.AppendLine($""); + sb.AppendLine(""); + } - sb.AppendLine(""); - sb.AppendLine("
HeaderValue
{headerName}{headerValues}
{headerName}{headerValues}
"); + sb.AppendLine(""); + sb.AppendLine(""); - return sb.ToString(); - } - - /// - /// Removes all files in the directory - /// - /// - internal static void ClearFiles(string directoryPath) { - var files = Directory.GetFiles(directoryPath); - if (files.Length == 0) { - return; + return sb.ToString(); } - foreach (var file in files) { - try { - File.Delete(file); - } catch { - // ignored - } + + /// + /// Removes all files in the directory + /// + /// + internal static void ClearFiles(string directoryPath) { + string[] files = Directory.GetFiles(directoryPath); + foreach (var file in files) { + try { + File.Delete(file); + } catch { + // ignored + } + } } - } } \ No newline at end of file diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index c3946a6..4c2285c 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -1,9 +1,7 @@ -using static PrettyConsole.Console; -using PrettyConsole; - -using Pulse.Configuration; using System.Net; +using System.Runtime.CompilerServices; +using Pulse.Configuration; namespace Pulse.Core; @@ -21,9 +19,9 @@ public static Color GetPercentageBasedColor(double percentage) { ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)percentage, 100); return percentage switch { - >= 75 => Color.Green, - >= 50 => Color.Yellow, - _ => Color.Red + >= 75 => Green, + >= 50 => Yellow, + _ => Red }; } @@ -34,38 +32,38 @@ public static Color GetPercentageBasedColor(double percentage) { /// public static Color GetStatusCodeBasedColor(int statusCode) { return statusCode switch { - < 100 => Color.Magenta, - < 200 => Color.White, - < 300 => Color.Green, - < 400 => Color.Yellow, - < 600 => Color.Red, - _ => Color.Magenta + < 100 => Magenta, + < 200 => White, + < 300 => Green, + < 400 => Yellow, + < 600 => Red, + _ => Magenta }; } /// - /// Returns a colored header for the request - /// - /// - public static ColoredOutput[] CreateHeader(Request request) { - Color color = request.Method.Method switch { - "GET" => Color.Green, - "DELETE" => Color.Red, - "POST" => Color.Magenta, - _ => Color.Yellow + /// Returns a color based on HttpMethod + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Color GetMethodBasedColor(string method) + => method switch { + "GET" => Green, + "DELETE" => Red, + "POST" => Magenta, + _ => Yellow }; - return [request.Method.Method * color, " => ", request.Url]; - } - /// /// Configures SSL handling /// /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy proxy) { if (proxy.IgnoreSSL) { - handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; + handler.SslOptions.RemoteCertificateValidationCallback = static (_, _, _, _) => true; } } @@ -73,23 +71,22 @@ public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy p /// Prints the exception /// /// - public static void PrintException(this StrippedException e, int indent = 0) { - Span padding = stackalloc char[indent]; - padding.Fill(' '); - Error.Write(padding); - WriteLine(["Exception Type" * Color.Yellow, ": ", e.Type], OutputPipe.Error); - Error.Write(padding); - WriteLine(["Message" * Color.Yellow, ": ", e.Message], OutputPipe.Error); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void PrintException(this StrippedException e) { + WriteLine(OutputPipe.Error, $"{Yellow}Exception type: {Default}{e.Type}"); + WriteLine(OutputPipe.Error, $"{Yellow}Message: {Default}{e.Message}"); + if (e.Detail is not null) { - Error.Write(padding); - WriteLine(["Detail" * Color.Yellow, ": ", e.Detail], OutputPipe.Error); + WriteLine(OutputPipe.Error, $"{Yellow}Detail: {Default}{e.Detail}"); } + if (e.InnerException is null or { IsDefault: true }) { return; } - Error.Write(padding); - Error.WriteLine("Inner Exception:"); - PrintException(e.InnerException, indent + 2); + + NewLine(OutputPipe.Error); + WriteLine($"{Magenta}Inner exception:"); + PrintException(e.InnerException); } /// @@ -97,6 +94,7 @@ public static void PrintException(this StrippedException e, int indent = 0) { /// /// /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string? AddExceptionDetail(Exception exception) { switch (exception) { case HttpRequestException: { diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index 3a634ea..18d91ea 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -10,123 +10,111 @@ namespace Pulse.Core; /// IPulseMonitor defines the traits for the wrappers that handles display of metrics and cross-thread data collection /// public interface IPulseMonitor { - /// - /// Creates a new pulse monitor according the verbosity setting - /// - /// - /// - /// - public static IPulseMonitor Create(HttpClient client, Request requestRecipe, Parameters parameters) { - if (parameters.Verbose) { - return new VerbosePulseMonitor(client, requestRecipe, parameters); - } - return new PulseMonitor(client, requestRecipe, parameters); - } + /// + /// Creates a new pulse monitor according the verbosity setting + /// + /// + /// + /// + public static IPulseMonitor Create(HttpClient client, Request requestRecipe, Parameters parameters) { + if (parameters.Verbose || parameters.Requests == 1) { + return new VerbosePulseMonitor(client, requestRecipe, parameters); + } + return new PulseMonitor(client, requestRecipe, parameters); + } - /// - /// Observe needs to be used instead of the execution delegate - /// - /// - Task SendAsync(int requestId); + /// + /// Observe needs to be used instead of the execution delegate + /// + /// + Task SendAsync(int requestId); - /// - /// Consolidates the results into an object - /// - PulseResult Consolidate(); + /// + /// Run cleanup and return results + /// + PulseResult ClearAndReturn(); - /// - /// Request execution context - /// - internal sealed class RequestExecutionContext { - private PaddedULong _currentConcurrentConnections; + /// + /// Request execution context + /// + internal sealed class RequestExecutionContext { + private PaddedULong _currentConcurrentConnections; - /// - /// Sends a request - /// - /// The request id - /// The recipe for the - /// The to use - /// Whether to save the content - /// The cancellation token - /// - public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { - HttpStatusCode statusCode = 0; - string content = string.Empty; - long contentLength = 0; - int currentConcurrencyLevel = 0; - StrippedException exception = StrippedException.Default; - var headers = Enumerable.Empty>>(); - using var message = requestRecipe.CreateMessage(); - long start = Stopwatch.GetTimestamp(); - TimeSpan elapsed = TimeSpan.Zero; - HttpResponseMessage? response = null; - try { - currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); - response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - if (cancellationToken.IsCancellationRequested) { - throw; - } - exception = new StrippedException - (nameof(TimeoutException), $"Request {id} interrupted by manual timeout"); - } catch (Exception) { - throw; - } finally { - Interlocked.Decrement(ref _currentConcurrentConnections.Value); - } - elapsed = Stopwatch.GetElapsedTime(start); - if (!exception.IsDefault) { - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } + /// + /// Sends a request. + /// + /// The request identifier. + /// The recipe used to build the request message. + /// Configured instance. + /// Whether response content should be persisted. + /// Cancellation token for the i/o operation. + /// A describing the outcome. + public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { + HttpStatusCode statusCode = 0; + string content = string.Empty; + long contentLength = 0; + int currentConcurrencyLevel = 0; + StrippedException exception = StrippedException.Default; + var headers = Enumerable.Empty>>(); + using var message = requestRecipe.CreateMessage(); + long start = Stopwatch.GetTimestamp(); + TimeSpan elapsed = TimeSpan.Zero; + HttpResponseMessage? response = null; + try { + currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); + response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + } catch (TimeoutException ex) { + exception = StrippedException.FromException(ex); + } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { + exception = StrippedException.FromException(timeoutEx); + } finally { + Interlocked.Decrement(ref _currentConcurrentConnections.Value); + } + elapsed = Stopwatch.GetElapsedTime(start); + if (!exception.IsDefault) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } - try { - var r = response!; - statusCode = r.StatusCode; - headers = r.Headers; - var length = r.Content.Headers.ContentLength; - if (length.HasValue) { - contentLength = length.Value; - } - if (saveContent) { - content = await r.Content.ReadAsStringAsync(cancellationToken); - if (contentLength == 0) { - var charSet = r.Content.Headers.ContentType?.CharSet; - var encoding = charSet is null - ? Encoding.UTF8 // doesn't exist - fallback to UTF8 - : Encoding.GetEncoding(charSet.Trim('"')); // exist - use server's - contentLength = encoding.GetByteCount(content.AsSpan()); - } - } - } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - if (cancellationToken.IsCancellationRequested) { - throw; - } - exception = new StrippedException - (nameof(TimeoutException), $"Reading request {id} content interrupted by manual timeout"); - } catch (Exception) { - throw; - } finally { - response?.Dispose(); - } - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - } + try { + var r = response!; + statusCode = r.StatusCode; + headers = r.Headers; + var length = r.Content.Headers.ContentLength; + if (length.HasValue) { + contentLength = length.Value; + } + if (saveContent) { + content = await r.Content.ReadAsStringAsync(cancellationToken); + if (contentLength == 0) { + var charSet = r.Content.Headers.ContentType?.CharSet; + var encoding = charSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(charSet.Trim('"')); + contentLength = encoding.GetByteCount(content.AsSpan()); + } + } + } finally { + response?.Dispose(); + } + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + } } \ No newline at end of file diff --git a/src/Pulse/Core/PaddedULong.cs b/src/Pulse/Core/PaddedULong.cs index c89ed91..41e0bd2 100644 --- a/src/Pulse/Core/PaddedULong.cs +++ b/src/Pulse/Core/PaddedULong.cs @@ -4,5 +4,5 @@ namespace Pulse.Core; [StructLayout(LayoutKind.Sequential, Size = 64)] internal struct PaddedULong { - public ulong Value; + public ulong Value; } \ No newline at end of file diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index eeff5c5..563e90c 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -11,123 +11,40 @@ public static class Pulse { /// /// /// - public static Task RunAsync(Parameters parameters, RequestDetails requestDetails) { - if (parameters.Requests is 1 || parameters.ExecutionMode is ExecutionMode.Sequential) { - return RunSequential(parameters, requestDetails); - } - - return parameters.MaxConnectionsModified - ? RunBounded(parameters, requestDetails) - : RunUnbounded(parameters, requestDetails); - } - - /// - /// Runs the pulse sequentially - /// - /// - /// - internal static async Task RunSequential(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); - - var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); - - for (int i = 1; i <= parameters.Requests; i++) { - await Task.Delay(parameters.DelayInMs); - await monitor.SendAsync(i); - } - - var result = monitor.Consolidate(); - - var summary = new PulseSummary { - Result = result, - Parameters = parameters, - RequestSizeInBytes = requestDetails.Request.GetRequestLength() - }; - - var (exportRequired, uniqueRequests) = summary.Summarize(); - - if (exportRequired) { - await summary.ExportUniqueRequestsAsync(uniqueRequests, parameters.CancellationToken); - } - } - - /// - /// Runs the pulse in parallel batches - /// - /// - /// - internal static async Task RunBounded(Parameters parameters, RequestDetails requestDetails) { + public static async Task RunAsync(Parameters parameters, RequestDetails requestDetails) { using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); var cancellationToken = parameters.CancellationToken; var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); - using var semaphore = new SemaphoreSlim(parameters.MaxConnections); + using var semaphore = new SemaphoreSlim(Math.Max(1, parameters.Connections)); var tasks = new Task[parameters.Requests]; - for (int i = 0; i < parameters.Requests; i++) { - await semaphore.WaitAsync(cancellationToken); - -#pragma warning disable IDE0053 // Use expression body for lambda expression - // lambda expression will change return type - tasks[i] = monitor.SendAsync(i + 1).ContinueWith(_ => { - semaphore.Release(); - }); -#pragma warning restore IDE0053 // Use expression body for lambda expression - + for (int i = 0; i < tasks.Length; i++) { + var requestId = i + 1; + tasks[i] = Task.Run(async () => { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try { + await monitor.SendAsync(requestId).ConfigureAwait(false); + if (parameters.DelayInMs > 0) { + await Task.Delay(parameters.DelayInMs, cancellationToken).ConfigureAwait(false); + } + } finally { + semaphore.Release(); + } + }, cancellationToken); } - await Task.WhenAll(tasks.AsSpan()).WaitAsync(cancellationToken).ConfigureAwait(false); - - var result = monitor.Consolidate(); - - var summary = new PulseSummary { - Result = result, - Parameters = parameters, - RequestSizeInBytes = requestDetails.Request.GetRequestLength() - }; - - var (exportRequired, uniqueRequests) = summary.Summarize(); - - if (exportRequired) { - await summary.ExportUniqueRequestsAsync(uniqueRequests, cancellationToken); - } - } - - /// - /// Runs the pulse in parallel without any batching - /// - /// - /// - internal static async Task RunUnbounded(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); - - var cancellationToken = parameters.CancellationToken; - - var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); - - var tasks = new Task[parameters.Requests]; - - for (int i = 0; i < parameters.Requests; i++) { - tasks[i] = monitor.SendAsync(i + 1); - } - - await Task.WhenAll(tasks.AsSpan()).WaitAsync(cancellationToken).ConfigureAwait(false); - - var result = monitor.Consolidate(); + await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false); - var summary = new PulseSummary { - Result = result, - Parameters = parameters, - RequestSizeInBytes = requestDetails.Request.GetRequestLength() - }; + var result = monitor.ClearAndReturn(); - var (exportRequired, uniqueRequests) = summary.Summarize(); + var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, result, requestDetails.Request.GetRequestLength()); if (exportRequired) { - await summary.ExportUniqueRequestsAsync(uniqueRequests, cancellationToken); + await PulseSummary.ExportUniqueRequestsAsync(parameters, uniqueRequests, cancellationToken); } } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index a0ae578..3e9733e 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Threading; using Sharpify; @@ -10,41 +11,45 @@ namespace Pulse.Core; public static class PulseHttpClientFactory { /// /// Creates an HttpClient with the specified - /// - /// - /// - /// An HttpClient - public static HttpClient Create(Proxy proxyDetails, int timeoutInMs) { - SocketsHttpHandler handler = CreateHandler(proxyDetails); - - return new HttpClient(handler) { - Timeout = TimeSpan.FromMilliseconds(timeoutInMs) - }; - } - - /// - /// Creates a with the specified /// /// - /// - internal static SocketsHttpHandler CreateHandler(Proxy proxyDetails) { - SocketsHttpHandler handler; - if (proxyDetails.Bypass || proxyDetails.Host.IsNullOrWhiteSpace()) { - handler = new SocketsHttpHandler(); - } else { - var proxy = new WebProxy(proxyDetails.Host); - if (!proxyDetails.Username.IsNullOrWhiteSpace() && !proxyDetails.Password.IsNullOrWhiteSpace()) { - proxy.Credentials = new NetworkCredential { - UserName = proxyDetails.Username, - Password = proxyDetails.Password - }; - } - handler = new SocketsHttpHandler() { - UseProxy = true, - Proxy = proxy - }; - } - handler.ConfigureSslHandling(proxyDetails); - return handler; - } + /// + /// An HttpClient + public static HttpClient Create(Proxy proxyDetails, int timeoutInMs) { + SocketsHttpHandler handler = CreateHandler(proxyDetails); + + var client = new HttpClient(handler) { + Timeout = timeoutInMs < 0 + ? Timeout.InfiniteTimeSpan + : TimeSpan.FromMilliseconds(timeoutInMs) + }; + + return client; + } + + /// + /// Creates a with the specified + /// + /// + /// + internal static SocketsHttpHandler CreateHandler(Proxy proxyDetails) { + SocketsHttpHandler handler; + if (proxyDetails.Bypass || proxyDetails.Host.IsNullOrWhiteSpace()) { + handler = new SocketsHttpHandler(); + } else { + var proxy = new WebProxy(proxyDetails.Host); + if (!proxyDetails.Username.IsNullOrWhiteSpace() && !proxyDetails.Password.IsNullOrWhiteSpace()) { + proxy.Credentials = new NetworkCredential { + UserName = proxyDetails.Username, + Password = proxyDetails.Password + }; + } + handler = new SocketsHttpHandler() { + UseProxy = true, + Proxy = proxy + }; + } + handler.ConfigureSslHandling(proxyDetails); + return handler; + } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index f8a085d..0caaad5 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -1,165 +1,131 @@ using System.Collections.Concurrent; using System.Diagnostics; -using static PrettyConsole.Console; -using PrettyConsole; -using Sharpify; +using Pulse.Configuration; using static Pulse.Core.IPulseMonitor; -using Pulse.Configuration; namespace Pulse.Core; + /// /// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// public sealed class PulseMonitor : IPulseMonitor { - /// - /// Holds the results of all the requests - /// - private readonly ConcurrentStack _results; - - private readonly char[] _etaBuffer = new char[30]; - - /// - /// Timestamp of the beginning of monitoring - /// - private readonly long _start; - - /// - /// Current number of responses received - /// - private PaddedULong _responses; - - // response status code counter - // 0: exception - // 1: 1xx - // 2: 2xx - // 3: 3xx - // 4: 4xx - // 5: 5xx - private readonly PaddedULong[] _stats = new PaddedULong[6]; - private readonly RequestExecutionContext _requestExecutionContext; - - private readonly int _requestCount; - private readonly bool _saveContent; - private readonly CancellationToken _cancellationToken; - private readonly HttpClient _httpClient; - private readonly Request _requestRecipe; - - private readonly Lock _lock = new(); - - /// - /// Creates a new pulse monitor - /// - public PulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { - _results = new ConcurrentStack(); - _requestCount = parameters.Requests; - _saveContent = parameters.Export; - _cancellationToken = parameters.CancellationToken; - _httpClient = client; - _requestRecipe = requestRecipe; - _requestExecutionContext = new RequestExecutionContext(); - PrintInitialMetrics(); - _start = Stopwatch.GetTimestamp(); - } + /// + /// Holds the results of all the requests + /// + private readonly ConcurrentStack _results; + + /// + /// Timestamp of the beginning of monitoring + /// + private readonly long _start; + + /// + /// Current number of responses received + /// + private PaddedULong _responses; + + // response status code counter + // 0: exception + // 1: 1xx + // 2: 2xx + // 3: 3xx + // 4: 4xx + // 5: 5xx + private readonly PaddedULong[] _stats = new PaddedULong[6]; + private readonly RequestExecutionContext _requestExecutionContext; + private readonly int _requestCount; + private readonly bool _saveContent; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _httpClient; + private readonly Request _requestRecipe; + + private readonly Lock _lock = new(); + + /// + /// Creates a new pulse monitor + /// + public PulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { + _results = new ConcurrentStack(); + _requestCount = parameters.Requests; + _saveContent = parameters.Export; + _cancellationToken = parameters.CancellationToken; + _httpClient = client; + _requestRecipe = requestRecipe; + _requestExecutionContext = new RequestExecutionContext(); + PrintInitialMetrics(); + _start = Stopwatch.GetTimestamp(); + } /// public async Task SendAsync(int requestId) { - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); - Interlocked.Increment(ref _responses.Value); - // Increment stats - int index = (int)result.StatusCode / 100; - Interlocked.Increment(ref _stats[index].Value); - // Print metrics - PrintMetrics(); - _results.Push(result); - } - - /// - /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. - /// - private void PrintMetrics() { - lock (_lock) { - var elapsed = Stopwatch.GetElapsedTime(_start).TotalMilliseconds; - - var eta = TimeSpan.FromMilliseconds(elapsed / _responses.Value * (_requestCount - (int)_responses.Value)); - - double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); - - var currentLine = GetCurrentLine(); - // Clear - ClearNextLines(2, OutputPipe.Error); - // Line 1 - Error.Write("Completed: "); - SetColors(Color.Yellow, Color.DefaultBackgroundColor); - Error.Write(_responses.Value); - ResetColors(); - Error.Write('/'); - SetColors(Color.Yellow, Color.DefaultBackgroundColor); - Error.Write(_requestCount); - ResetColors(); - Error.Write(", SR: "); - SetColors(Helper.GetPercentageBasedColor(sr), Color.DefaultBackgroundColor); - Error.Write(sr); - ResetColors(); - Error.Write("%, ETA: "); - Write(Utils.DateAndTime.FormatTimeSpan(eta, _etaBuffer), OutputPipe.Error, Color.Yellow); - NewLine(OutputPipe.Error); - - // Line 2 - Error.Write("1xx: "); - SetColors(Color.White, Color.DefaultBackgroundColor); - Error.Write(_stats[1].Value); - ResetColors(); - Error.Write(", 2xx: "); - SetColors(Color.Green, Color.DefaultBackgroundColor); - Error.Write(_stats[2].Value); - ResetColors(); - Error.Write(", 3xx: "); - SetColors(Color.Yellow, Color.DefaultBackgroundColor); - Error.Write(_stats[3].Value); - ResetColors(); - Error.Write(", 4xx: "); - SetColors(Color.Red, Color.DefaultBackgroundColor); - Error.Write(_stats[4].Value); - ResetColors(); - Error.Write(", 5xx: "); - SetColors(Color.Red, Color.DefaultBackgroundColor); - Error.Write(_stats[5].Value); - ResetColors(); - Error.Write(", others: "); - SetColors(Color.Magenta, Color.DefaultBackgroundColor); - Error.Write(_stats[0].Value); - ResetColors(); - NewLine(OutputPipe.Error); - // Reset location - GoToLine(currentLine); - } - } - - /// - /// Prints the initial metrics to establish ui - /// - private void PrintInitialMetrics() { - lock (_lock) { - var currentLine = GetCurrentLine(); - // Clear - ClearNextLines(2, OutputPipe.Error); - // Line 1 - WriteLine(["Completed: ", "0" * Color.Yellow, $"/{_requestCount}, SR: ", "0%" * Color.Red, ", ETA: ", "NaN" * Color.Yellow], OutputPipe.Error); - - // Line 2 - WriteLine(["1xx: ", "0" * Color.White, ", 2xx: ", "0" * Color.Green, ", 3xx: ", "0" * Color.Yellow, ", 4xx: ", "0" * Color.Red, ", 5xx: ", "0" * Color.Red, ", others: ", "0" * Color.Magenta], OutputPipe.Error); - // Reset location - GoToLine(currentLine); - } - } + var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); + Interlocked.Increment(ref _responses.Value); + // Increment stats + + int index = (int)result.StatusCode / 100; + Interlocked.Increment(ref _stats[index].Value); + // Print metrics + + PrintMetrics(); + _results.Push(result); + } + + /// + /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PrintMetrics() { + lock (_lock) { + var elapsed = Stopwatch.GetElapsedTime(_start).TotalMilliseconds; + var eta = TimeSpan.FromMilliseconds(elapsed / _responses.Value * (_requestCount - (int)_responses.Value)); + double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); + + var stats = new Stats { + CurrentCount = _responses, + RequestCount = _requestCount, + StatusCodes = _stats, + ETA = eta, + SuccessRate = sr + }; + + Overwrite(stats, static s => { + WriteLine(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{Default}/{Yellow}{s.RequestCount}{Default}, SR: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{Default}%, ETA: {Yellow}{s.ETA:hr}"); + WriteLine(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{Default}, 2xx: {Green}{s.StatusCodes[2].Value}{Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{Default}, 4xx: {Red}{s.StatusCodes[4].Value}{Default}, 5xx: {Red}{s.StatusCodes[5].Value}{Default}, others: {Magenta}{s.StatusCodes[0].Value}"); + }, 2, OutputPipe.Error); + } + } + + private readonly ref struct Stats { + public required PaddedULong CurrentCount { get; init; } + public required PaddedULong[] StatusCodes { get; init; } + public required TimeSpan ETA { get; init; } + public required double SuccessRate { get; init; } + public required int RequestCount { get; init; } + } + + /// + /// Prints the initial metrics to establish ui + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PrintInitialMetrics() { + Overwrite(_requestCount, static requests => { + WriteLine(OutputPipe.Error, $"Completed: {Yellow}0{Default}/{Yellow}{requests}{Default}, SR: {Red}0{Default}%, ETA: {Yellow}NaN"); + WriteLine(OutputPipe.Error, $"1xx: {White}0{Default}, 2xx: {Green}0{Default}, 3xx: {Yellow}0{Default}, 4xx: {Red}0{Default}, 5xx: {Red}0{Default}, others: {Magenta}0"); + }, 2, OutputPipe.Error); + } /// - public PulseResult Consolidate() => new() { - Results = _results, - SuccessRate = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2), - TotalDuration = Stopwatch.GetElapsedTime(_start) - }; + public PulseResult ClearAndReturn() { + // Clear after metrics + ClearNextLines(2, OutputPipe.Error); + + return new() { + Results = _results, + SuccessRate = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2), + TotalDuration = Stopwatch.GetElapsedTime(_start) + }; + } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseResult.cs b/src/Pulse/Core/PulseResult.cs index 7e56b08..5816ec6 100644 --- a/src/Pulse/Core/PulseResult.cs +++ b/src/Pulse/Core/PulseResult.cs @@ -5,19 +5,19 @@ namespace Pulse.Core; /// /// Result of pulse (complete test) /// -public record PulseResult { - /// - /// Results of the individual requests - /// - public required ConcurrentStack Results { get; init; } +public readonly ref struct PulseResult { + /// + /// Results of the individual requests + /// + public required ConcurrentStack Results { get; init; } - /// - /// Total duration of the pulse - /// - public required TimeSpan TotalDuration { get; init; } + /// + /// Total duration of the pulse + /// + public required TimeSpan TotalDuration { get; init; } - /// - /// Success rate (percentage of 2xx responses) - /// - public required double SuccessRate { get; init; } + /// + /// Success rate (percentage of 2xx responses) + /// + public required double SuccessRate { get; init; } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 120165b..3af32c4 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -1,302 +1,270 @@ using System.Net; -using static PrettyConsole.Console; -using PrettyConsole; -using Pulse.Configuration; -using Sharpify; using System.Numerics; -using Sharpify.Collections; +using System.Runtime.InteropServices; using System.Runtime.Intrinsics; +using Pulse.Configuration; + +using Sharpify; + namespace Pulse.Core; /// /// Pulse summary handles outputs and experts post-pulse /// -public sealed class PulseSummary { - /// - /// The size of the request in bytes - /// - public required long RequestSizeInBytes { get; init; } - - /// - /// The pulse result - /// - public required PulseResult Result { get; init; } - - /// - /// The pulse parameters - /// - public required Parameters Parameters { get; init; } - - private readonly Lock _lock = new(); - - /// - /// Produces a summary, and saves unique requests if export is enabled. - /// - /// Value indicating whether export is required, and the requests to export (null if not required) - public (bool exportRequired, HashSet uniqueRequests) Summarize() { - var completed = Result.Results.Count; - if (completed is 1) { - return SummarizeSingle(); - } - - lock (_lock) { - HashSet uniqueRequests = Parameters.Export - ? new HashSet(new ResponseComparer(Parameters)) - : []; - Dictionary statusCounter = []; - var latencies = new double[completed]; - var latencyManager = BufferWrapper.Create(latencies); - var sizes = new double[completed]; - var sizeManager = BufferWrapper.Create(sizes); - - long totalSize = 0; - int peakConcurrentConnections = 0; - - var currentLine = GetCurrentLine(); - -#if !DEBUG - OverrideCurrentLine(["Cross referencing results..."], OutputPipe.Error); -#endif - foreach (var result in Result.Results) { - uniqueRequests.Add(result); - var statusCode = result.StatusCode; - statusCounter.GetValueRefOrAddDefault(statusCode, out _)++; - totalSize += RequestSizeInBytes; - peakConcurrentConnections = Math.Max(peakConcurrentConnections, result.CurrentConcurrentConnections); - - if (!result.Exception.IsDefault) { - continue; - } - - // duration - var latency = result.Latency.TotalMilliseconds; - latencyManager.Append(latency); - // size - var size = result.ContentLength; - if (size > 0) { - sizeManager.Append(size); - if (Parameters.Export) { - totalSize += size; - } - } - } - Summary latencySummary = GetSummary(latencies.AsSpan(0, latencyManager.Position)); - Summary sizeSummary = GetSummary(sizes.AsSpan(0, sizeManager.Position), false); - Func getSize = Utils.Strings.FormatBytes; - double throughput = totalSize / Result.TotalDuration.TotalSeconds; -#if !DEBUG - OverrideCurrentLine(["Cross referencing results...", " done!" * Color.Green], OutputPipe.Error); - OverrideCurrentLine([]); -#endif - - if (Parameters.Verbose) { - NewLine(OutputPipe.Error); - } else { - ClearNextLines(3, OutputPipe.Out); - } - - static string Outliers(int n) => n is 1 ? "outlier" : "outliers"; - - WriteLine(["Request count: ", $"{completed}" * Color.Yellow]); - WriteLine(["Concurrent connections: ", $"{peakConcurrentConnections}" * Color.Yellow]); - WriteLine(["Total duration: ", Utils.DateAndTime.FormatTimeSpan(Result.TotalDuration) * Color.Yellow]); - WriteLine(["Success Rate: ", $"{Result.SuccessRate}%" * Helper.GetPercentageBasedColor(Result.SuccessRate)]); - Write(["Latency: Min: ", $"{latencySummary.Min:0.##}ms" * Color.Cyan, ", Mean: ", $"{latencySummary.Mean:0.##}ms" * Color.Yellow, ", Max: ", $"{latencySummary.Max:0.##}ms" * Color.Red]); - if (latencySummary.Removed is 0) { - NewLine(); - } else { - Out.WriteLine($" (Removed {latencySummary.Removed} {Outliers(latencySummary.Removed)})"); - } - WriteLine(["Content Size: Min: ", getSize(sizeSummary.Min) * Color.Cyan, ", Mean: ", getSize(sizeSummary.Mean) * Color.Yellow, ", Max: ", getSize(sizeSummary.Max) * Color.Red]); - WriteLine(["Total throughput: ", $"{getSize(throughput)}/s" * Color.Yellow]); - Out.WriteLine("Status codes:"); - foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { - var key = (int)kvp.Key; - if (key is 0) { - WriteLine([$" {key}" * Color.Magenta, $" --> {kvp.Value} [StatusCode 0 = Exception]"]); - } else { - WriteLine([$" {key}" * Helper.GetStatusCodeBasedColor(key), $" --> {kvp.Value}"]); - } - } - NewLine(); - - return (Parameters.Export, uniqueRequests); - } - } - - /// - /// Produces a summary for a single result - /// - /// Value indicating whether export is required, and the requests to export (null if not required) - public (bool exportRequired, HashSet uniqueRequests) SummarizeSingle() { - lock (_lock) { - var result = Result.Results.First(); - double duration = result.Latency.TotalMilliseconds; - var statusCode = result.StatusCode; - - if (Parameters.Verbose) { - NewLine(OutputPipe.Error); - } else { - ClearNextLines(3, OutputPipe.Out); - } - - WriteLine(["Request count: ", "1" * Color.Yellow]); - WriteLine(["Total duration: ", Utils.DateAndTime.FormatTimeSpan(Result.TotalDuration) * Color.Yellow]); - if ((int)statusCode is >= 200 and < 300) { - WriteLine(["Success: ", "true" * Color.Green]); - } else { - WriteLine(["Success: ", "false" * Color.Red]); - } - WriteLine(["Latency: ", $"{duration:0.##}ms" * Color.Cyan]); - WriteLine(["Content Size: ", Utils.Strings.FormatBytes(result.ContentLength) * Color.Cyan]); - if (statusCode is 0) { - WriteLine(["Status code: ", "0 [Exception]" * Color.Red]); - } else { - WriteLine(["Status code: ", $"{statusCode}" * Helper.GetStatusCodeBasedColor((int)statusCode)]); - } - NewLine(); - - var uniqueRequests = new HashSet(1) { result }; - - return (Parameters.Export, uniqueRequests); - } - } - - /// - /// Creates an IQR summary from - /// - /// - /// - internal static Summary GetSummary(Span values, bool removeOutliers = true) { - // if conditions ordered to promote default paths - if (values.Length > 2) { - values.Sort(); - - if (!removeOutliers) { - return SummarizeOrderedSpan(values, 0); - } - - int i25 = values.Length / 4, i75 = 3 * values.Length / 4; - double q1 = values[i25]; // First quartile - double q3 = values[i75]; // Third quartile - double iqr = q3 - q1; - double lowerBound = q1 - 1.5 * iqr; - double upperBound = q3 + 1.5 * iqr; - - int start = FindBoundIndex(values, lowerBound, 0, i25); - int end = FindBoundIndex(values, upperBound, i75, values.Length); - ReadOnlySpan filtered = values.Slice(start, end - start); - - return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); - } else if (values.Length is 2) { - return new Summary { - Min = Math.Min(values[0], values[1]), - Max = Math.Max(values[0], values[1]), - Mean = (values[0] + values[1]) / 2 - }; - } else if (values.Length is 1) { - return new Summary { - Min = values[0], - Max = values[0], - Mean = values[0] - }; - } else { - return new(); - } - } - - internal static int FindBoundIndex(ReadOnlySpan orderedValues, double bound, int clampMin, int clampMax) { - int index = orderedValues.BinarySearch(bound); - if (index < 0) { - index = ~index; // Get the insertion point - } - return Math.Clamp(index, clampMin, clampMax); - } - - internal static Summary SummarizeOrderedSpan(ReadOnlySpan values, int removed) { - return new Summary { - Min = values[0], - Max = values[values.Length - 1], - Mean = Mean(values), - Removed = removed - }; - } - - internal struct Summary { - public double Min; - public double Max; - public double Mean; - public int Removed; - } - - internal static double Mean(ReadOnlySpan span) { - double mean = 0; - double reciprocal = 1.0 / span.Length; - int i = 0; - - // Process data in chunks of vectorSize - if (Vector512.IsHardwareAccelerated) { - int vectorSize = Vector512.Count; +public static class PulseSummary { + /// + /// Produces a summary, and saves unique requests if export is enabled. + /// + /// Value indicating whether export is required, and the requests to export (null if not required) + public static (bool exportRequired, HashSet uniqueRequests) Summarize(Parameters parameters, PulseResult pulseResult, long requestSizeInBytes) { + var completed = pulseResult.Results.Count; + if (completed is 1) { + return SummarizeSingle(parameters, pulseResult); + } + + HashSet uniqueRequests = parameters.Export + ? new HashSet(new ResponseComparer(parameters)) + : []; + Dictionary statusCounter = []; + var latencies = new List(completed); + var sizes = new List(completed); + + long totalSize = 0; + int peakConcurrentConnections = 0; + + Overwrite(() => { + WriteLine($"Cross referencing results..."); + }, 1, OutputPipe.Error); + + foreach (var result in pulseResult.Results) { + uniqueRequests.Add(result); + var statusCode = result.StatusCode; + statusCounter.GetValueRefOrAddDefault(statusCode, out _)++; + totalSize += requestSizeInBytes; + peakConcurrentConnections = Math.Max(peakConcurrentConnections, result.CurrentConcurrentConnections); + + if (!result.Exception.IsDefault) { + continue; + } + + // duration + var latency = result.Latency.TotalMilliseconds; + latencies.Add(latency); + // size + var size = result.ContentLength; + if (size > 0) { + sizes.Add(size); + if (parameters.Export) { + totalSize += size; + } + } + } + Summary latencySummary = GetSummary(CollectionsMarshal.AsSpan(latencies)); + Summary sizeSummary = GetSummary(CollectionsMarshal.AsSpan(sizes), false); + Func getSize = Utils.Strings.FormatBytes; + double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; + + Overwrite(() => { + WriteLine($"Cross referencing results... {Green}done!"); + }, 1, OutputPipe.Error); + ClearNextLines(1, OutputPipe.Error); + + WriteLine($"Request count: {Yellow}{completed}"); + WriteLine($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); + WriteLine($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + WriteLine($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); + WriteLine($"Latency: Min: {Cyan}{latencySummary.Min:0.##}ms{Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{Default}, Max: {Red}{latencySummary.Max:0.##}ms"); + if (latencySummary.Removed != 0) { + WriteLine($" (Removed {latencySummary.Removed} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); + } + WriteLine($"Content Size: Min: {Cyan}{getSize(sizeSummary.Min)}{Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{Default}, Max: {Red}{getSize(sizeSummary.Max)}"); + WriteLine($"Total throughput: {Yellow}{getSize(throughput)}/s"); + WriteLine($"Status codes:"); + foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { + var key = (int)kvp.Key; + if (key is 0) { + WriteLine($" {Magenta}{key}{Default} --> {kvp.Value} [StatusCode 0 = Exception]"); + } else { + WriteLine($" {Helper.GetStatusCodeBasedColor(key)}{key}{Default} --> {kvp.Value}"); + } + } + NewLine(); + + return (parameters.Export, uniqueRequests); + } + + /// + /// Produces a summary for a single result + /// + /// Value indicating whether export is required, and the requests to export (null if not required) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static (bool exportRequired, HashSet uniqueRequests) SummarizeSingle(Parameters parameters, PulseResult pulseResult) { + var result = pulseResult.Results.First(); + double duration = result.Latency.TotalMilliseconds; + var statusCode = result.StatusCode; + + WriteLine($"Request count: {Yellow}1"); + WriteLine($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + if ((int)statusCode is >= 200 and < 300) { + WriteLine($"Success: {Green}true"); + } else { + WriteLine($"Success: {Red}false"); + } + WriteLine($"Latency: {Cyan}{duration:0.##}ms"); + WriteLine($"Content Size: {Cyan}{Utils.Strings.FormatBytes(result.ContentLength)}"); + if (statusCode is 0) { + WriteLine($"Status code: {Red}0 [Exception]"); + } else { + WriteLine($"Status code: {Helper.GetStatusCodeBasedColor((int)statusCode)}{statusCode}"); + } + NewLine(); + + var uniqueRequests = new HashSet(1) { result }; + + return (parameters.Export, uniqueRequests); + } + + + /// + /// Creates an IQR summary from + /// + /// + /// + internal static Summary GetSummary(Span values, bool removeOutliers = true) { + // if conditions ordered to promote default paths + + if (values.Length > 2) { + values.Sort(); + + if (!removeOutliers) { + return SummarizeOrderedSpan(values, 0); + } + + int i25 = values.Length / 4, i75 = 3 * values.Length / 4; + double q1 = values[i25]; // First quartile + + double q3 = values[i75]; // Third quartile + + double iqr = q3 - q1; + double lowerBound = q1 - 1.5 * iqr; + double upperBound = q3 + 1.5 * iqr; + + int start = FindBoundIndex(values, lowerBound, 0, i25); + int end = FindBoundIndex(values, upperBound, i75, values.Length); + ReadOnlySpan filtered = values.Slice(start, end - start); + + return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); + } else if (values.Length is 2) { + return new Summary { + Min = Math.Min(values[0], values[1]), + Max = Math.Max(values[0], values[1]), + Mean = (values[0] + values[1]) / 2 + }; + } else if (values.Length is 1) { + return new Summary { + Min = values[0], + Max = values[0], + Mean = values[0] + }; + } else { + return new(); + } + } + + internal static int FindBoundIndex(ReadOnlySpan orderedValues, double bound, int clampMin, int clampMax) { + int index = orderedValues.BinarySearch(bound); + if (index < 0) { + index = ~index; // Get the insertion point + + } + return Math.Clamp(index, clampMin, clampMax); + } + + internal static Summary SummarizeOrderedSpan(ReadOnlySpan values, int removed) { + return new Summary { + Min = values[0], + Max = values[values.Length - 1], + Mean = Mean(values), + Removed = removed + }; + } + + internal struct Summary { + public double Min; + public double Max; + public double Mean; + public int Removed; + } + + internal static double Mean(ReadOnlySpan span) { + double mean = 0; + double reciprocal = 1.0 / span.Length; + int i = 0; + + // Process data in chunks of vectorSize + if (Vector512.IsHardwareAccelerated) { + int vectorSize = Vector512.Count; var r = Vector512.Create(reciprocal); - while (i <= span.Length - vectorSize) { - var vector = Vector512.Create(span.Slice(i, vectorSize)); + while (i <= span.Length - vectorSize) { + var vector = Vector512.Create(span.Slice(i, vectorSize)); var product = Vector512.Multiply(vector, r); mean += Vector512.Sum(product); - i += vectorSize; - } - } else { - int vectorSize = Vector.Count; + i += vectorSize; + } + } else { + int vectorSize = Vector.Count; var r = Vector.Create(reciprocal); - while (i <= span.Length - vectorSize) { - var vector = Vector.Create(span.Slice(i, vectorSize)); - var product = Vector.Multiply(vector, r); + while (i <= span.Length - vectorSize) { + var vector = Vector.Create(span.Slice(i, vectorSize)); + var product = Vector.Multiply(vector, r); mean += Vector.Sum(product); - i += vectorSize; - } - } - - // Process remaining elements - double scalerSum = 0; - for (; i < span.Length; i++) { - scalerSum += span[i]; - } - mean += scalerSum * reciprocal; - - return mean; - } - - /// - /// Exports unique request results asynchronously and in parallel if possible - /// - /// - /// - /// - public async Task ExportUniqueRequestsAsync(HashSet uniqueRequests, CancellationToken token = default) { - var count = uniqueRequests.Count; - - if (count is 0) { - WriteLine("No unique results found to export..." * Color.Yellow); - return; - } - - string directory = Path.Join(Directory.GetCurrentDirectory(), Parameters.OutputFolder); - Directory.CreateDirectory(directory); - Exporter.ClearFiles(directory); - - if (count is 1) { - await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, Parameters, token); - WriteLine(["1" * Color.Cyan, $" unique response exported to ", Parameters.OutputFolder * Color.Yellow, " folder"]); - return; - } - - var options = new ParallelOptions { - MaxDegreeOfParallelism = Environment.ProcessorCount, - CancellationToken = token - }; - - await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, Parameters, tkn)); - - WriteLine([$"{count}" * Color.Cyan, " unique responses exported to ", Parameters.OutputFolder * Color.Yellow, " folder"]); - } + i += vectorSize; + } + } + + // Process remaining elements + double scalerSum = 0; + for (; i < span.Length; i++) { + scalerSum += span[i]; + } + mean += scalerSum * reciprocal; + + return mean; + } + + /// + /// Exports unique request results asynchronously and in parallel if possible + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests, CancellationToken token = default) { + var count = uniqueRequests.Count; + + if (count is 0) { + WriteLine($"{Yellow}No unique results found to export..."); + return; + } + + string directory = Path.Join(Directory.GetCurrentDirectory(), parameters.OutputFolder); + Directory.CreateDirectory(directory); + Exporter.ClearFiles(directory); + + if (count is 1) { + await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token); + WriteLine($"{Cyan}1{Default} unique response exported to {Yellow}{parameters.OutputFolder}{Default}."); + return; + } + + var options = new ParallelOptions { + MaxDegreeOfParallelism = Environment.ProcessorCount, + CancellationToken = token + }; + + await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn)); + + WriteLine($"{Cyan}{count}{Default} unique responses exported to {Yellow}{parameters.OutputFolder}{Default}."); + } } \ No newline at end of file diff --git a/src/Pulse/Core/RawFailure.cs b/src/Pulse/Core/RawFailure.cs index b38db8a..b254eb2 100644 --- a/src/Pulse/Core/RawFailure.cs +++ b/src/Pulse/Core/RawFailure.cs @@ -7,9 +7,9 @@ namespace Pulse.Core; /// public readonly struct RawFailure { public RawFailure() { - Headers = []; - StatusCode = 0; - Content = string.Empty; + Headers = []; + StatusCode = 0; + Content = string.Empty; } /// @@ -17,13 +17,13 @@ public RawFailure() { /// public int StatusCode { get; init; } - /// - /// Response headers - /// - public Dictionary> Headers { get; init; } + /// + /// Response headers + /// + public Dictionary> Headers { get; init; } - /// - /// Response content (if any) - /// - public string Content { get; init; } = string.Empty; + /// + /// Response content (if any) + /// + public string Content { get; init; } = string.Empty; } \ No newline at end of file diff --git a/src/Pulse/Core/ReleaseInfo.cs b/src/Pulse/Core/ReleaseInfo.cs index 7bed6b0..2d237cd 100644 --- a/src/Pulse/Core/ReleaseInfo.cs +++ b/src/Pulse/Core/ReleaseInfo.cs @@ -6,9 +6,9 @@ namespace Pulse.Core; /// Release information /// public sealed class ReleaseInfo { - /// - /// Version is taken from the release tag - /// - [JsonPropertyName("tag_name")] - public string? Version { get; set; } + /// + /// Version is taken from the release tag + /// + [JsonPropertyName("tag_name")] + public string? Version { get; set; } } \ No newline at end of file diff --git a/src/Pulse/Core/RequestDetails.cs b/src/Pulse/Core/RequestDetails.cs index 30c370a..69ab0d9 100644 --- a/src/Pulse/Core/RequestDetails.cs +++ b/src/Pulse/Core/RequestDetails.cs @@ -9,156 +9,154 @@ namespace Pulse.Core; /// Request details /// public class RequestDetails { - /// - /// Proxy configuration - /// - public Proxy Proxy { get; set; } = new(); - - /// - /// Request configuration - /// - public Request Request { get; set; } = new(); + /// + /// Proxy configuration + /// + public Proxy Proxy { get; set; } = new(); + + /// + /// Request configuration + /// + public Request Request { get; set; } = new(); } /// /// Proxy configuration /// public class Proxy { - /// - /// Don't use proxy - /// - public bool Bypass { get; set; } = true; - - /// - /// Ignore SSL errors - /// - public bool IgnoreSSL { get; set; } - - /// - /// Host - /// - public string Host { get; set; } = string.Empty; - - /// - /// Proxy authentication username - /// - public string Username { get; set; } = string.Empty; - - /// - /// Proxy authentication password - /// - public string Password { get; set; } = string.Empty; + /// + /// Don't use proxy + /// + public bool Bypass { get; set; } = true; + + /// + /// Ignore SSL errors + /// + public bool IgnoreSSL { get; set; } + + /// + /// Host + /// + public string Host { get; set; } = string.Empty; + + /// + /// Proxy authentication username + /// + public string Username { get; set; } = string.Empty; + + /// + /// Proxy authentication password + /// + public string Password { get; set; } = string.Empty; } /// /// Request configuration /// public class Request { - public const string DefaultUrl = "https://ipinfo.io/geo"; - - /// - /// Request URL - defaults to https://ipinfo.io/geo - /// - public string Url { get; set; } = DefaultUrl; - - /// - /// Request method - defaults to GET - /// - public HttpMethod Method { get; set; } = HttpMethod.Get; - - /// - /// Request headers - /// - public Dictionary Headers { get; set; } = []; - - /// - /// The request content - /// - public Content Content { get; set; } = new(); - - /// - /// Create an http request message from the configuration - /// - /// - public HttpRequestMessage CreateMessage() { - var message = new HttpRequestMessage(Method, Url); - - foreach (var header in Headers) { - if (header.Value is null) { - continue; - } - var value = header.Value.ToString(); - message.Headers.TryAddWithoutValidation(header.Key, value); - } - - if (Content.Body.HasValue) { - var media = Content.GetContentType(); - var messageContent = Content.Body.ToString()!; - Debug.Assert(messageContent is not null); - - message.Content = new StringContent(messageContent, Encoding.UTF8, media); - } - - return message; - } - - /// - /// Returns the request size in bytes - /// - public long GetRequestLength() { - long length = 0; - const long contentTypeHeaderLength = 14; // "Content-Type: " - - foreach (var header in Headers) { - if (header.Value is null) { - continue; - } - var value = header.Value.ToString(); - length += GetLength(header.Key); - length += 2 + GetLength(value); - } - - if (Content.Body.HasValue) { - var media = Content.GetContentType(); - var messageContent = Content.Body.ToString()!; - Debug.Assert(messageContent is not null); - length += contentTypeHeaderLength + GetLength(media); - length += GetLength(messageContent); - } - - return length; - - static long GetLength(ReadOnlySpan span) { - return Encoding.Default.GetByteCount(span); - } - } + public const string DefaultUrl = "https://ipinfo.io/geo"; + + /// + /// Request URL - defaults to https://ipinfo.io/geo + /// + public string Url { get; set; } = DefaultUrl; + + /// + /// Request method - defaults to GET + /// + public HttpMethod Method { get; set; } = HttpMethod.Get; + + /// + /// Request headers + /// + public Dictionary Headers { get; set; } = []; + + /// + /// The request content + /// + public Content Content { get; set; } = new(); + + /// + /// Create an http request message from the configuration + /// + /// + public HttpRequestMessage CreateMessage() { + var message = new HttpRequestMessage(Method, Url); + + foreach (var header in Headers) { + if (header.Value is null) { + continue; + } + var value = header.Value.ToString(); + message.Headers.TryAddWithoutValidation(header.Key, value); + } + + if (Content.Body.HasValue) { + var media = Content.GetContentType(); + var messageContent = Content.Body.ToString()!; + Debug.Assert(messageContent is not null); + + message.Content = new StringContent(messageContent, Encoding.UTF8, media); + } + + return message; + } + + /// + /// Returns the request size in bytes + /// + public long GetRequestLength() { + long length = 0; + const long contentTypeHeaderLength = 14; // "Content-Type: " + Encoding encoding = Encoding.Default; + + + foreach (var header in Headers) { + if (header.Value is null) { + continue; + } + var value = header.Value.ToString(); + length += encoding.GetByteCount(header.Key); + length += 2 + encoding.GetByteCount(value.AsSpan()); + } + + if (Content.Body.HasValue) { + var media = Content.GetContentType(); + var messageContent = Content.Body.ToString()!; + Debug.Assert(messageContent is not null); + length += contentTypeHeaderLength + encoding.GetByteCount(media); + length += encoding.GetByteCount(messageContent); + } + + return length; + } } /// /// Request content /// public readonly struct Content { - [JsonConstructor] - public Content() { - ContentType = string.Empty; - Body = null; - } - - /// - /// Declares the content type - /// - public string ContentType { get; init; } - - /// - /// Content - /// - public JsonElement? Body { get; init; } - - /// - /// Returns the content type after defaulting if empty - /// - /// - public string GetContentType() => ContentType.Length is 0 - ? "application/json" - : ContentType; + [JsonConstructor] + public Content() { + ContentType = string.Empty; + Body = null; + } + + /// + /// Declares the content type + /// + public string ContentType { get; init; } + + /// + /// Content + /// + public JsonElement? Body { get; init; } + + /// + /// Returns the content type after defaulting if empty + /// + /// + public string GetContentType() => ContentType.Length is 0 + ? "application/json" + : ContentType; } \ No newline at end of file diff --git a/src/Pulse/Core/Response.cs b/src/Pulse/Core/Response.cs index b8678e6..a261118 100644 --- a/src/Pulse/Core/Response.cs +++ b/src/Pulse/Core/Response.cs @@ -1,3 +1,4 @@ +using System; using System.Net; using Pulse.Configuration; @@ -8,108 +9,112 @@ namespace Pulse.Core; /// The model used for response /// public readonly record struct Response { - /// - /// The id of the request - /// - public required int Id { get; init; } - - /// - /// Status code (null if it produced an exception) - /// - public required HttpStatusCode StatusCode { get; init; } - - /// - /// Headers (could be null if exception occurred, or server didn't include it) - /// - public required IEnumerable>> Headers { get; init; } - - /// - /// Content (could be empty if exception occurred, no export feature is used or server didn't include it) - /// - public required string Content { get; init; } - - /// - /// The response content length - /// - public required long ContentLength { get; init; } - - /// - /// The time taken from sending the request to receiving the response headers - /// - public required TimeSpan Latency { get; init; } - - /// - /// The exception (if occurred) - /// - public required StrippedException Exception { get; init; } - - /// - /// The current number of concurrent connections at the time of the request - /// - public required int CurrentConcurrentConnections { get; init; } + /// + /// The id of the request + /// + public required int Id { get; init; } + + /// + /// Status code (null if it produced an exception) + /// + public required HttpStatusCode StatusCode { get; init; } + + /// + /// Headers (could be null if exception occurred, or server didn't include it) + /// + public required IEnumerable>> Headers { get; init; } + + /// + /// Content (could be empty if exception occurred, no export feature is used or server didn't include it) + /// + public required string Content { get; init; } + + /// + /// The response content length + /// + public required long ContentLength { get; init; } + + /// + /// The time taken from sending the request to receiving the response headers + /// + public required TimeSpan Latency { get; init; } + + /// + /// The exception (if occurred) + /// + public required StrippedException Exception { get; init; } + + /// + /// The current number of concurrent connections at the time of the request + /// + public required int CurrentConcurrentConnections { get; init; } } /// /// Request comparer to be used in HashSets /// public sealed class ResponseComparer : IEqualityComparer { - private readonly Parameters _parameters; - - public ResponseComparer(Parameters parameters) { - _parameters = parameters; - } - - /// - /// Equality here does not take into account all properties as some are not valuable for the response itself, - /// for instance ThreadId doesn't matter for response equality, neither duration. - /// - /// - /// - /// - /// If is not used, the content is only checked by length to accelerate checks, generally this is sufficient as usually a website won't return same length responses for different results - /// - /// - public bool Equals(Response x, Response y) { // Equals is only used if HashCode is equal - bool basicEquality = x.StatusCode == y.StatusCode; - - if (_parameters.UseFullEquality) { - basicEquality &= string.Equals(x.Content, y.Content, StringComparison.Ordinal); - } else { - basicEquality &= x.ContentLength == y.ContentLength; - } - - if (!basicEquality) { - return false; - } - - // Compare Exception types and messages - - if (x.Exception.IsDefault != y.Exception.IsDefault) { - return false; - } - - return x.Exception.Message == y.Exception.Message; - } - - /// - /// HashCode doesn't check exception because more complicated equality checks are needed. - /// - /// - /// - public int GetHashCode(Response obj) { - int hashStatusCode = obj.StatusCode.GetHashCode(); - - int hash = 17; - hash = hash * 23 + hashStatusCode; - - if (obj.Exception.IsDefault) { - // no exception -> should have content - hash = hash * 23 + obj.Content.GetHashCode(); - } else { - // exception = no content (usually) - hash = hash * 23 + obj.Exception.Message.GetHashCode(); - } - - return hash; - } + private readonly Parameters _parameters; + + public ResponseComparer(Parameters parameters) { + _parameters = parameters; + } + + /// + /// Equality here does not take into account all properties as some are not valuable for the response itself, + /// for instance ThreadId doesn't matter for response equality, neither duration. + /// + /// + /// + /// + /// If is not used, the content is only checked by length to accelerate checks, generally this is sufficient as usually a website won't return same length responses for different results + /// + /// + public bool Equals(Response x, Response y) { // Equals is only used if HashCode is equal + bool basicEquality = x.StatusCode == y.StatusCode; + + if (_parameters.UseFullEquality) { + basicEquality &= string.Equals(x.Content, y.Content, StringComparison.Ordinal); + } else { + basicEquality &= x.ContentLength == y.ContentLength; + } + + if (!basicEquality) { + return false; + } + + // Compare Exception types and messages + + if (x.Exception.IsDefault != y.Exception.IsDefault) { + return false; + } + + return x.Exception.Message == y.Exception.Message; + } + + /// + /// HashCode doesn't check exception because more complicated equality checks are needed. + /// + /// + /// + public int GetHashCode(Response obj) { + int hashStatusCode = obj.StatusCode.GetHashCode(); + + int hash = 17; + hash = hash * 23 + hashStatusCode; + + if (obj.Exception.IsDefault) { + // no exception -> should have content + if (_parameters.UseFullEquality) { + hash = hash * 23 + StringComparer.Ordinal.GetHashCode(obj.Content); + } else { + hash = hash * 23 + obj.ContentLength.GetHashCode(); + } + } else { + // exception = no content (usually) + hash = hash * 23 + StringComparer.Ordinal.GetHashCode(obj.Exception.Message); + } + + return hash; + } } \ No newline at end of file diff --git a/src/Pulse/Core/SendCommand.cs b/src/Pulse/Core/SendCommand.cs deleted file mode 100644 index ee996f3..0000000 --- a/src/Pulse/Core/SendCommand.cs +++ /dev/null @@ -1,269 +0,0 @@ -using Pulse.Configuration; -using Sharpify.CommandLineInterface; -using Sharpify; -using PrettyConsole; -using static PrettyConsole.Console; -using System.Text.Json; -using System.Text.Json.Schema; - -namespace Pulse.Core; - -/// -/// The main command -/// -public sealed class SendCommand : Command { - private readonly CancellationToken _cancellationToken; - - /// - /// The constructor of the command - /// - /// A global cancellation token source that will propagate to all tasks - public SendCommand(CancellationToken cancellationToken) { - _cancellationToken = cancellationToken; - } - - public override string Name => string.Empty; - public override string Description => string.Empty; - public override string Usage => - """ - Pulse [RequestFile] [Options] - - RequestFile: - path to .json request details file - - If you don't have one use the "get-sample" command - Options: - -n, --number : number of total requests (default: 1) - -t, --timeout : timeout in milliseconds (default: -1 - infinity) - -m, --mode : execution mode (default: parallel) - * sequential = execute requests sequentially - --delay : delay between requests in milliseconds (default: 0) - * parallel = execute requests using maximum resources - -c : max concurrent connections (default: infinity) - --json : try to format response content as JSON - --raw : export raw results (without wrapping in custom html) - -f : use full equality (slower - default: false) - --no-export : don't export results (default: false) - -v, --verbose : display verbose output (default: false) - -o, --output : output folder (default: results) - Special: - get-sample : command - generates sample file - get-schema : command - generates a json schema file - check-for-updates: command - checks for updates - terms-of-use : print the terms of use - --noop : print selected configuration but don't run - -u, --url : override the url of the request - -h, --help : print this help text - Notes: - * when "-n" is 1, verbose output is enabled - """; - - internal static ParametersBase ParseParametersArgs(Arguments args) { - args.TryGetValue(["n", "number"], ParametersBase.DefaultNumberOfRequests, out int requests); - requests = Math.Max(requests, 1); - args.TryGetValue(["t", "timeout"], ParametersBase.DefaultTimeoutInMs, out int timeoutInMs); - bool batchSizeModified = false; - int maxConnections = 0; - int delayInMs = 0; - args.TryGetEnum(["m", "mode"], ParametersBase.DefaultExecutionMode, true, out ExecutionMode mode); - if (mode is ExecutionMode.Parallel) { - if (args.TryGetValue("c", ParametersBase.DefaultMaxConnections, out maxConnections)) { - batchSizeModified = true; - } - } else if (mode is ExecutionMode.Sequential) { - args.TryGetValue("delay", 0, out delayInMs); - delayInMs = Math.Max(0, delayInMs); - } - args.TryGetValue(["o", "output"], "results", out string outputFolder); - maxConnections = Math.Max(maxConnections, ParametersBase.DefaultMaxConnections); - bool formatJson = args.HasFlag("json"); - bool exportRaw = args.HasFlag("raw"); - bool exportFullEquality = args.HasFlag("f"); - bool disableExport = args.HasFlag("no-export"); - bool noop = args.HasFlag("noop"); - bool verbose = args.HasFlag("v") || args.HasFlag("verbose") || requests is 1; - return new ParametersBase { - Requests = requests, - TimeoutInMs = timeoutInMs, - DelayInMs = delayInMs, - ExecutionMode = mode, - MaxConnections = maxConnections, - MaxConnectionsModified = batchSizeModified, - FormatJson = formatJson, - ExportRaw = exportRaw, - UseFullEquality = exportFullEquality, - Export = !disableExport, - NoOp = noop, - Verbose = verbose, - OutputFolder = outputFolder - }; - } - - /// - /// Gets the request details from the specified file - /// - /// - /// - /// - internal static Result GetRequestDetails(string requestSource, Arguments args) { - var path = Path.GetFullPath(requestSource); - var result = InputJsonContext.TryGetRequestDetailsFromFile(path); - if (args.TryGetValue(["u", "url"], out string url)) { - result.Value!.Request.Url = url; - } - return result; - } - - /// - /// Executes the command - /// - /// - public override async ValueTask ExecuteAsync(Arguments args) { - if (!args.TryGetValue(0, out string rf)) { - WriteLine("request file or command name must be provided!" * Color.Red, OutputPipe.Error); - return 1; - } - - if (SubCommands.TryGetValue(rf, out var subCommand)) { - try { - await subCommand(_cancellationToken); - return 0; - } catch (Exception e) { - WriteLine(e.Message * Color.Red, OutputPipe.Error); - return 1; - } - } - - var parametersBase = ParseParametersArgs(args); - var requestDetailsResult = GetRequestDetails(rf, args); - - if (requestDetailsResult.IsFail) { - WriteLine(requestDetailsResult.Message * Color.Red, OutputPipe.Error); - return 1; - } - - var requestDetails = requestDetailsResult.Value!; - var @params = new Parameters(parametersBase, _cancellationToken); - - if (@params.NoOp) { - PrintConfiguration(@params, requestDetails); - return 0; - } - - WriteLine(Helper.CreateHeader(requestDetails.Request)); - await Pulse.RunAsync(@params, requestDetails); - return 0; - } - - internal static readonly Dictionary> SubCommands = new(4, StringComparer.OrdinalIgnoreCase) { - ["get-sample"] = async token => { - var path = Path.Join(Directory.GetCurrentDirectory(), "sample.json"); - var json = JsonSerializer.Serialize(new RequestDetails(), InputJsonContext.Default.RequestDetails); - await File.WriteAllTextAsync(path, json, token); - WriteLine(["Sample request generated at ", path * Color.Yellow]); - }, - ["get-schema"] = async token => { - var path = Path.Join(Directory.GetCurrentDirectory(), "schema.json"); - var options = new JsonSchemaExporterOptions { - TreatNullObliviousAsNonNullable = true, - }; - var schema = InputJsonContext.Default.RequestDetails.GetJsonSchemaAsNode(options).ToString(); - await File.WriteAllTextAsync(path, schema, token); - WriteLine(["Schema generated at ", path * Color.Yellow]); - }, - ["check-for-updates"] = async token => { - using var client = new HttpClient(); - client.DefaultRequestHeaders.Add("User-Agent", "C# App"); - client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); - using var message = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/dusrdev/Pulse/releases/latest"); - using var response = await client.SendAsync(message, token); - if (response.IsSuccessStatusCode) { - var json = await response.Content.ReadAsStringAsync(token); - var result = DefaultJsonContext.DeserializeVersion(json); - if (result.IsFail) { - WriteLine(result.Message, OutputPipe.Error); - return; - } - var remoteVersion = result.Value; - var currentVersion = Version.Parse(Program.VERSION); - if (currentVersion < remoteVersion) { - WriteLine("A new version of Pulse is available!" * Color.Yellow); - WriteLine(["Your version: ", Program.VERSION * Color.Yellow]); - WriteLine(["Latest version: ", remoteVersion.ToString() * Color.Green]); - NewLine(); - WriteLine("Download from https://github.com/dusrdev/Pulse/releases/latest"); - } else { - WriteLine("You are using the latest version of Pulse." * Color.Green); - } - } else { - WriteLine("Failed to check for updates - server response was not success", OutputPipe.Error); - } - }, - ["terms-of-use"] = _ => { - Out.WriteLine( - """ - By using this tool you agree to take full responsibility for the consequences of its use. - - Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's - responsibility to obey all applicable local, state and federal laws. - Developers assume no liability and are not responsible for any misuse or damage caused by this program. - """ - ); - return ValueTask.CompletedTask; - } - }; - - /// - /// Prints the configuration - /// - /// - /// - internal static void PrintConfiguration(Parameters parameters, RequestDetails requestDetails) { - Color headerColor = Color.Cyan; - Color property = Color.DarkGray; - Color value = Color.White; - - // Options - WriteLine("Options:" * headerColor); - WriteLine([" Request count: " * property, $"{parameters.Requests}" * value]); - WriteLine([" Timeout: " * property, $"{parameters.TimeoutInMs}" * value]); - WriteLine([" Execution mode: " * property, $"{parameters.ExecutionMode}" * value]); - if (parameters.ExecutionMode is ExecutionMode.Parallel && parameters.MaxConnectionsModified) { - WriteLine([" Maximum concurrent connections: " * property, $"{parameters.MaxConnections}" * value]); - } - WriteLine([" Export Raw: " * property, $"{parameters.ExportRaw}" * value]); - WriteLine([" Format JSON: " * property, $"{parameters.FormatJson}" * value]); - WriteLine([" Export Full Equality: " * property, $"{parameters.UseFullEquality}" * value]); - WriteLine([" Export: " * property, $"{parameters.Export}" * value]); - WriteLine([" Verbose: " * property, $"{parameters.Verbose}" * value]); - WriteLine([" Output Folder: " * property, parameters.OutputFolder * value]); - - // Request - WriteLine("Request:" * headerColor); - WriteLine([" URL: " * property, requestDetails.Request.Url * value]); - WriteLine([" Method: " * property, requestDetails.Request.Method.ToString() * value]); - WriteLine(" Headers:" * Color.Yellow); - if (requestDetails.Request.Headers.Count > 0) { - foreach (var header in requestDetails.Request.Headers) { - if (header.Value is null) { - continue; - } - WriteLine([" ", header.Key * property, ": ", header.Value.Value.ToString() * value]); - } - } - if (requestDetails.Request.Content.Body.HasValue) { - WriteLine(" Content:" * Color.Yellow); - WriteLine([" ContentType: " * property, requestDetails.Request.Content.GetContentType() * value]); - WriteLine([" Body: " * property, requestDetails.Request.Content.Body.ToString()! * value]); - } else { - WriteLine([" Content: " * Color.Yellow, "none" * value]); - } - - // Proxy - WriteLine("Proxy:" * headerColor); - WriteLine([" Bypass: " * property, $"{requestDetails.Proxy.Bypass}" * value]); - WriteLine([" Host: " * property, requestDetails.Proxy.Host * value]); - WriteLine([" Username: " * property, requestDetails.Proxy.Username * value]); - WriteLine([" Password: " * property, requestDetails.Proxy.Password * value]); - WriteLine([" Ignore SSL: " * property, $"{requestDetails.Proxy.IgnoreSSL}" * value]); - } -} \ No newline at end of file diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 2485b49..359eaaa 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -1,9 +1,9 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Net; + using Pulse.Configuration; -using static PrettyConsole.Console; -using PrettyConsole; + using static Pulse.Core.IPulseMonitor; namespace Pulse.Core; @@ -12,89 +12,73 @@ namespace Pulse.Core; /// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// public sealed class VerbosePulseMonitor : IPulseMonitor { - /// - /// Holds the results of all the requests - /// - private readonly ConcurrentStack _results; + /// + /// Holds the results of all the requests + /// + private readonly ConcurrentStack _results; - /// - /// Timestamp of the beginning of monitoring - /// - private readonly long _start; + /// + /// Timestamp of the beginning of monitoring + /// + private readonly long _start; - /// - /// Current number of responses received - /// - private PaddedULong _responses; + /// + /// Current number of responses received + /// + private PaddedULong _responses; - /// - /// Current number of successful responses received - /// - private PaddedULong _successes; + /// + /// Current number of successful responses received + /// + private PaddedULong _successes; - private readonly bool _saveContent; - private readonly CancellationToken _cancellationToken; - private readonly HttpClient _httpClient; - private readonly Request _requestRecipe; - private readonly RequestExecutionContext _requestExecutionContext; + private readonly bool _saveContent; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _httpClient; + private readonly Request _requestRecipe; + private readonly RequestExecutionContext _requestExecutionContext; - private readonly Lock _lock = new(); + private readonly Lock _lock = new(); - /// - /// Creates a new verbose pulse monitor - /// - public VerbosePulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { - _results = new ConcurrentStack(); - _saveContent = parameters.Export; - _cancellationToken = parameters.CancellationToken; - _httpClient = client; - _requestRecipe = requestRecipe; - _requestExecutionContext = new RequestExecutionContext(); - _start = Stopwatch.GetTimestamp(); - } + /// + /// Creates a new verbose pulse monitor + /// + public VerbosePulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { + _results = new ConcurrentStack(); + _saveContent = parameters.Export; + _cancellationToken = parameters.CancellationToken; + _httpClient = client; + _requestRecipe = requestRecipe; + _requestExecutionContext = new RequestExecutionContext(); + _start = Stopwatch.GetTimestamp(); + } /// public async Task SendAsync(int requestId) { - PrintPreRequest(requestId); - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); - Interlocked.Increment(ref _responses.Value); - // Increment stats - if (result.StatusCode is HttpStatusCode.OK) { - Interlocked.Increment(ref _successes.Value); - } - int status = (int)result.StatusCode; - PrintPostRequest(requestId, status); - _results.Push(result); - } - - private void PrintPreRequest(int requestId) { - lock (_lock) { - Write("--> ", OutputPipe.Error, Color.Yellow); - Error.Write("Sent request: "); - SetColors(Color.Yellow, Color.DefaultBackgroundColor); - Error.WriteLine(requestId); - ResetColors(); - } - } - - private void PrintPostRequest(int requestId, int statusCode) { - lock (_lock) { - Write("<-- ", OutputPipe.Error, Color.Cyan); - Error.Write("Received response: "); - SetColors(Color.Yellow, Color.DefaultBackgroundColor); - Error.Write(requestId); - ResetColors(); - Error.Write(", status code: "); - SetColors(Helper.GetStatusCodeBasedColor(statusCode), Color.DefaultBackgroundColor); - Error.WriteLine(statusCode); - ResetColors(); - } - } + lock (_lock) { + WriteLine(OutputPipe.Error, $"{Yellow}--> {Default}Sent request: {Yellow}{requestId}"); + } + var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); + Interlocked.Increment(ref _responses.Value); + // Increment stats + if (result.StatusCode is HttpStatusCode.OK) { + Interlocked.Increment(ref _successes.Value); + } + int status = (int)result.StatusCode; + lock (_lock) { + WriteLine(OutputPipe.Error, $"{Yellow}<-- {Default}Received response: {Yellow}{requestId}{Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); + } + _results.Push(result); + } /// - public PulseResult Consolidate() => new() { - Results = _results, - SuccessRate = Math.Round((double)_successes.Value / _responses.Value * 100, 2), - TotalDuration = Stopwatch.GetElapsedTime(_start) - }; + public PulseResult ClearAndReturn() { + NewLine(OutputPipe.Error); + + return new() { + Results = _results, + SuccessRate = Math.Round((double)_successes.Value / _responses.Value * 100, 2), + TotalDuration = Stopwatch.GetElapsedTime(_start) + }; + } } \ No newline at end of file diff --git a/src/Pulse/GlobalUsings.cs b/src/Pulse/GlobalUsings.cs new file mode 100644 index 0000000..858a439 --- /dev/null +++ b/src/Pulse/GlobalUsings.cs @@ -0,0 +1,6 @@ +global using System.Runtime.CompilerServices; + +global using PrettyConsole; + +global using static PrettyConsole.Color; +global using static PrettyConsole.Console; diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index cc2e8fd..4c2bf72 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,54 +1,17 @@ -using Pulse.Configuration; -using Pulse.Core; - -using Sharpify.CommandLineInterface; - -using static PrettyConsole.Console; -using PrettyConsole; +using ConsoleAppFramework; -internal class Program { - internal const string VERSION = "1.2.0.0"; - - private static async Task Main(string[] args) { - using CancellationTokenSource globalCTS = new(); +using Pulse.Core; - System.Console.CancelKeyPress += (_, e) => { - e.Cancel = true; - globalCTS.Cancel(); - }; +ConsoleApp.Version = Commands.VERSION; - var firstLine = GetCurrentLine(); +var app = ConsoleApp.Create(); - var cli = CliRunner.CreateBuilder() - .AddCommand(new SendCommand(globalCTS.Token)) - .UseConsoleAsOutputWriter() - .WithMetadata(metadata => metadata.Version = VERSION) - .WithCustomHeader( - """ - Pulse - A hyper fast general purpose HTTP request tester +app.UseFilter(); - Repository: https://github.com/dusrdev/Pulse - """ - ) - .SetHelpTextSource(HelpTextSource.CustomHeader) - .Build(); +app.Add("", Commands.Root); +app.Add("get-sample", Commands.GetSample); +app.Add("get-schema", Commands.GetSchema); +app.Add("check-for-updates", Commands.CheckForUpdates); +app.Add("terms-of-use", Commands.TermsOfUse); - try { - return await cli.RunAsync(args, false); - } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - GoToLine(firstLine); - ClearNextLines(4, OutputPipe.Out); - ClearNextLines(4, OutputPipe.Error); - WriteLine("Cancellation requested and handled gracefully." * Color.DarkYellow); - return 1; - } catch (Exception e) { - GoToLine(firstLine); - ClearNextLines(4, OutputPipe.Out); - ClearNextLines(4, OutputPipe.Error); - WriteLine("Unexpected exception! Contact developer at dusrdev@gmail.com and provide the following:" * Color.Red, OutputPipe.Error); - NewLine(OutputPipe.Error); - Helper.PrintException(StrippedException.FromException(e)); - return 1; - } - } -} \ No newline at end of file +await app.RunAsync(args); \ No newline at end of file diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index da8435b..77c395c 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -7,24 +7,29 @@ enable full Speed - true - true - 1 + false + false true true true + false + false + 0 true false - 1.2.0.0 + 2.0.0.0 true https://github.com/dusrdev/Pulse git + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + - - + diff --git a/tests/Pulse.Tests.Unit/VersionTests.cs b/tests/Pulse.Tests.Unit/VersionTests.cs index 80d2321..72d1439 100644 --- a/tests/Pulse.Tests.Unit/VersionTests.cs +++ b/tests/Pulse.Tests.Unit/VersionTests.cs @@ -1,10 +1,12 @@ +using Pulse.Core; + namespace Pulse.Tests.Unit; public class VersionTests { [Fact] public void Assembly_Version_Matching() { // Arrange - var constantVersion = Version.Parse(Program.VERSION); + var constantVersion = Version.Parse(Commands.VERSION); var assemblyVersion = typeof(Program).Assembly.GetName().Version!; // Assert From 98a8eb81b3ba138b41fd3f2b1200f3c5c2614198 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 3 Nov 2025 20:32:38 +0200 Subject: [PATCH 005/105] Updated versions, changelog and readme --- Changelog.md | 18 ++++++++++--- History.md | 17 +++++++++++++ Readme.md | 72 ++++++++++++++++++++++++++-------------------------- 3 files changed, 67 insertions(+), 40 deletions(-) diff --git a/Changelog.md b/Changelog.md index 978a663..93a471d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,16 @@ # Changelog -- `--raw` export mode now outputs a special json with debug information for non-successful responses with no content, it contains: - - Response status code as integer - - Response headers - - Response content (if any) +- Updated `PrettyConsole` to latest version to use higher perf APIs. +- Moved from using `Sharpify.CommandLineInterface` to `ConsoleAppFramework` for better perf and less verbose code. +- Many different internals were optimized to provide higher stability and performance. +- `ExecutionMode` is no longer used, and the options were unified: + - To use `Sequential` mode, simply set `-c| --connections` to 1. + - By default `Parallel` number will be used and `connections` will be set to the number of requests. + - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. + - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. +- Many outputs show now be more consistent and artifact free, including when updating output is interrupted (like when press CTRL+C). +- Compilations options were refined to produce a even more purpose fit executable. + - The binary size should smaller. + - Startup times should be better. + - Potential delays due to GC should not be much less frequent. + - Hot-paths should perform even faster due to multi-level perf analysis. diff --git a/History.md b/History.md index 1a10ca9..8eb77a8 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,22 @@ # Changelog +## Version 2.0.0.0 + +- Updated `PrettyConsole` to latest version to use higher perf APIs. +- Moved from using `Sharpify.CommandLineInterface` to `ConsoleAppFramework` for better perf and less verbose code. +- Many different internals were optimized to provide higher stability and performance. +- `ExecutionMode` is no longer used, and the options were unified: + - To use `Sequential` mode, simply set `-c| --connections` to 1. + - By default `Parallel` number will be used and `connections` will be set to the number of requests. + - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. + - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. +- Many outputs show now be more consistent and artifact free, including when updating output is interrupted (like when press CTRL+C). +- Compilations options were refined to produce a even more purpose fit executable. + - The binary size should smaller. + - Startup times should be better. + - Potential delays due to GC should not be much less frequent. + - Hot-paths should perform even faster due to multi-level perf analysis. + ## Version 1.2.0.0 - `--raw` export mode now outputs a special json with debug information for non-successful responses with no content, it contains: diff --git a/Readme.md b/Readme.md index 6741f2d..c2504a3 100644 --- a/Readme.md +++ b/Readme.md @@ -6,7 +6,7 @@ Pulse is a general purpose, cross-platform, performance-oriented, command-line u - JSON based request configuration - Support for using proxies -- True multi-threading with configurable modes (max concurrency, batches, sequential) +- Configurable concurrency via max connection limits and optional per-request delays - Supports all HTTP methods - Supports Headers - Support Content-Type and Body for POST, PUT, PATCH, and DELETE @@ -44,7 +44,7 @@ After the execution (different configuration in this example), `Pulse` produces The configuration file is a JSON file that contains proxy information and the request details. -It is recommended to use the build in `get-sample` command to generate a sample configuration file. +It is recommended to use the built-in `get-sample` command to generate a sample configuration file. ```bash Pulse get-sample @@ -107,44 +107,44 @@ Content contains the configuration for the request content. Which is only used f Pulse has a wide range of options that can be configured in the command line, and can be viewed with `help` or `--help` which shows this: ```plaintext -Pulse [RequestFile] [Options] +Usage: [command] [arguments...] [options...] [-h|--help] [--version] + +Pulse - A hyper fast general purpose HTTP request tester + +Arguments: + [0] Path to .json request details file [use "get-sample" if you don't have one] -RequestFile: - path to .json request details file - - If you don't have one use the "get-sample" command Options: - -n, --number : number of total requests (default: 1) - -t, --timeout : timeout in milliseconds (default: -1 - infinity) - -m, --mode : execution mode (default: parallel) - * sequential = execute requests sequentially - --delay : delay between requests in milliseconds (default: 0) - * parallel = execute requests using maximum resources - -c : max concurrent connections (default: infinity) - --json : try to format response content as JSON - --raw : export raw results (without wrapping in custom html) - -f : use full equality (slower - default: false) - --no-export : don't export results (default: false) - -v, --verbose : display verbose output (default: false) - -o, --output : output folder (default: results) -Special: - get-sample : command - generates sample file - get-schema : command - generates a json schema file - check-for-updates: command - checks for updates - terms-of-use : print the terms of use - --noop : print selected configuration but don't run - -u, --url : override the url of the request - -h, --help : print this help text -Notes: - * when "-n" is 1, verbose output is enabled + --json Try to format response content as JSON (Optional) + --raw Export raw results [without wrapping in custom HTML] (Optional) + -f|--full-equality Use full equality [slower] (Optional) + --no-export Don't export results (Optional) + -v|--verbose Display verbose output (Optional) + --no-op Print selected configuration but don't run (Optional) + -o|--output Output folder (Default: @"results") + -d|--delay Delay in milliseconds between requests (Default: -1) + -c|--connections Maximum number of parallel requests (Default: null) + -u|--url Override the url of the request (Default: null) + -n|--number Number of total requests (Default: 1) + -t|--timeout Timeout in milliseconds (Default: -1) + +Commands: + check-for-updates Checks whether there is a new version out on GitHub releases. + get-sample Generate sample request file + get-schema Generate a json schema file + terms-of-use Print the terms of use. ``` -- `--json` - try to format response content as JSON -- `--raw` - export raw results (without wrapping in custom html) -- `-v` or `--verbose` - display verbose output, this changes the output format, instead of displaying a dashboard, it prints requests/responses as they are being processed. -- `f` - use fully equality: by default because response content can be entire webpages, it can be a time consuming and resource heavy operation to make sure all responses are unique, so by default a simpler check is used which only compares the content length - for most cases this is sufficient since you usually expect the same content for the requests, but you can opt in for full equality. -- `u` or `url` - can be used to override the url of the request, this can be useful if you want to keep all other settings the same, and quickly change the url of the request. -- `noop` - is a very useful command which will print the selected configuration but not perform the pulse, this can be used to inspect the request settings after they are parsed by the program, to ensure everything is exactly as you intended. -- `o` or `output` - can be used to specify the output folder, by default it is "results", but you can specify a different folder if you want to. +- `--json` - try to format response content as JSON. +- `--raw` - export raw results without custom HTML; can be combined with `--json`. +- `-f|--full-equality` - enforce full response equality checks instead of length-based comparisons. +- `-v|--verbose` - display per-request logging instead of the dashboard UI. +- `--no-op` - print the parsed configuration without running any requests. +- `-c|--connections` - cap parallel requests; set to `1` for sequential execution or leave unset to match the total request count. +- `-d|--delay` - add a delay (ms) after each request completes; useful when `--connections` is `1`. +- `-u|--url` - override the request URL while keeping the rest of the configuration unchanged. +- `-o|--output` - choose a custom output directory (defaults to `results`). +- `-n|--number` and `-t|--timeout` - control how many requests run and the per-request timeout (ms). ## Disclaimer From dfff56b5d8dbf298e169c45618bd8425dc8e0b1b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 4 Nov 2025 10:30:31 +0200 Subject: [PATCH 006/105] Added stub for aot profiling --- .gitignore | 14 +++++++++ profiling/pulse_profiler/Cargo.lock | 7 +++++ profiling/pulse_profiler/Cargo.toml | 6 ++++ profiling/pulse_profiler/src/main.rs | 45 ++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+) create mode 100644 profiling/pulse_profiler/Cargo.lock create mode 100644 profiling/pulse_profiler/Cargo.toml create mode 100644 profiling/pulse_profiler/src/main.rs diff --git a/.gitignore b/.gitignore index ada3b75..7063259 100644 --- a/.gitignore +++ b/.gitignore @@ -484,3 +484,17 @@ $RECYCLE.BIN/ *.swp .vscode/launch.json .vscode/tasks.json + +# Cargo artifacts +target/ + +# Generated documentation +doc/ + +# Dependencies and build caches (if not managed by Cargo directly) +# For example, if you're using a build system that places dependencies outside target/ +# vendor/ + +# Editor swap files +*~ +*.swp \ No newline at end of file diff --git a/profiling/pulse_profiler/Cargo.lock b/profiling/pulse_profiler/Cargo.lock new file mode 100644 index 0000000..6396fea --- /dev/null +++ b/profiling/pulse_profiler/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "pulse_profiler" +version = "0.1.0" diff --git a/profiling/pulse_profiler/Cargo.toml b/profiling/pulse_profiler/Cargo.toml new file mode 100644 index 0000000..0156aab --- /dev/null +++ b/profiling/pulse_profiler/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "pulse_profiler" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/profiling/pulse_profiler/src/main.rs b/profiling/pulse_profiler/src/main.rs new file mode 100644 index 0000000..6df4d01 --- /dev/null +++ b/profiling/pulse_profiler/src/main.rs @@ -0,0 +1,45 @@ +use std::{path::Path, process::{Command, Stdio, ExitCode}}; + +fn main() -> ExitCode { + let commands: Vec<&str> = vec![ + "--help", + "get-sample", + "" + ]; + + for command in &commands { + let code = run_pulse(&command); + + if code != 0 { + return ExitCode::from(1); + } + } + + return ExitCode::from(0); +} + +fn run_pulse(args: &str) -> i32 { + let path = &Path::new("../../../src/Pulse/publish/Pulse"); + + let mut binding = Command::new(path); + + let command = binding + .arg(&args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + + let output = command.output().unwrap(); + + if !output.status.success() { + // preserve stderr in the error + let code = output.status.code().unwrap(); + let err = String::from_utf8_lossy(&output.stderr); + println!("Command failed with code: ${code}"); + println!("Message:"); + println!("${err}"); + return code; + } + + return 0; +} \ No newline at end of file From 3916f20bdd26e4724c6f4bdeba2ed3781cc4b090 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 4 Nov 2025 11:08:53 +0200 Subject: [PATCH 007/105] Added rust local server to profile pulse and compiler flags for perf --- profiling/pulse_profiler/Cargo.toml | 12 + profiling/pulse_profiler/src/main.rs | 12 +- profiling/pulse_profiler_server/Cargo.lock | 573 ++++++++++++++++++ profiling/pulse_profiler_server/Cargo.toml | 20 + .../pulse_profiler_server/data/payload.html | 209 +++++++ .../pulse_profiler_server/data/payload.json | 59 ++ profiling/pulse_profiler_server/src/main.rs | 38 ++ 7 files changed, 920 insertions(+), 3 deletions(-) create mode 100644 profiling/pulse_profiler_server/Cargo.lock create mode 100644 profiling/pulse_profiler_server/Cargo.toml create mode 100644 profiling/pulse_profiler_server/data/payload.html create mode 100644 profiling/pulse_profiler_server/data/payload.json create mode 100644 profiling/pulse_profiler_server/src/main.rs diff --git a/profiling/pulse_profiler/Cargo.toml b/profiling/pulse_profiler/Cargo.toml index 0156aab..21baef4 100644 --- a/profiling/pulse_profiler/Cargo.toml +++ b/profiling/pulse_profiler/Cargo.toml @@ -4,3 +4,15 @@ version = "0.1.0" edition = "2024" [dependencies] + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" + +[profile.dev] +opt-level = 2 +debug = 1 +incremental = true \ No newline at end of file diff --git a/profiling/pulse_profiler/src/main.rs b/profiling/pulse_profiler/src/main.rs index 6df4d01..b4ff1ad 100644 --- a/profiling/pulse_profiler/src/main.rs +++ b/profiling/pulse_profiler/src/main.rs @@ -4,7 +4,11 @@ fn main() -> ExitCode { let commands: Vec<&str> = vec![ "--help", "get-sample", - "" + "sample.json -u \"http://127.0.0.1:3000/\"", // one string + "sample.json -n 100 -u \"http://127.0.0.1:3000/json\" --json", // 100 json with formatting + "sample.json -n 100 -c 10 -u \"http://127.0.0.1:3000/html\" --raw", // 100 html limited raw + "sample.json -n 10 -u \"http://127.0.0.1:3000/html\" --raw -f", // 10 html full equality + "sample.json -n 100 -v -u \"http://127.0.0.1:3000/html\" --raw", // 100 html parallel verbose (race) raw ]; for command in &commands { @@ -19,11 +23,13 @@ fn main() -> ExitCode { } fn run_pulse(args: &str) -> i32 { + let dir = &Path::new("../../../src/Pulse/publish/"); let path = &Path::new("../../../src/Pulse/publish/Pulse"); let mut binding = Command::new(path); let command = binding + .current_dir(&dir) .arg(&args) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -35,9 +41,9 @@ fn run_pulse(args: &str) -> i32 { // preserve stderr in the error let code = output.status.code().unwrap(); let err = String::from_utf8_lossy(&output.stderr); - println!("Command failed with code: ${code}"); + println!("Command failed with code: {code}"); println!("Message:"); - println!("${err}"); + println!("{err}"); return code; } diff --git a/profiling/pulse_profiler_server/Cargo.lock b/profiling/pulse_profiler_server/Cargo.lock new file mode 100644 index 0000000..cd7bcfb --- /dev/null +++ b/profiling/pulse_profiler_server/Cargo.lock @@ -0,0 +1,573 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulse_profiler_server" +version = "0.1.0" +dependencies = [ + "axum", + "tokio", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" diff --git a/profiling/pulse_profiler_server/Cargo.toml b/profiling/pulse_profiler_server/Cargo.toml new file mode 100644 index 0000000..bf3c4fb --- /dev/null +++ b/profiling/pulse_profiler_server/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pulse_profiler_server" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.6" +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } + +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" + +[profile.dev] +opt-level = 2 +debug = 1 +incremental = true diff --git a/profiling/pulse_profiler_server/data/payload.html b/profiling/pulse_profiler_server/data/payload.html new file mode 100644 index 0000000..5dcd859 --- /dev/null +++ b/profiling/pulse_profiler_server/data/payload.html @@ -0,0 +1,209 @@ + + + + + + Test Payload + + + +
+

Large HTML Test Data

+

This document contains a significant amount of text to simulate a real-world webpage. The goal is to provide a non-trivial payload for HTTP load testing, specifically for profiling the client's parsing and handling capabilities.

+ +
+

Section 1

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+
+

Section 2

+

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi.

+
+
+

Section 3

+

Curabitur bibendum, erat id consequat consequat, odio + urna + suscipit + sapien, + nec + sollicitudin + nisl + erat + vel + enim. + Maecenas + viverra + condimentum + erat. + Aenean + porttitor, + mauris + eget + aliquam + dictum, + massa + erat + sodales + justo, + ac + rhoncus + elit + purus + eget + massa. + Donec + velit + est, + lobortis + quis, + vulputate + sit + amet, + molestie + id, + magna. + Vivamus + consequat, + felis + id + pulvinar + ullamcorper, + nunc + erat + id + sapien, + id + faucibus + sapien + odio + vel + pede.

+
+
+

Section 4

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+
+

Section 5

+

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi.

+
+
+

Section 6

+

Curabitur bibendum, erat id consequat consequat, odio + urna + suscipit + sapien, + nec + sollicitudin + nisl + erat + vel + enim. + Maecenas + viverra + condimentum + erat. + Aenean + porttitor, + mauris + eget + aliquam + dictum, + massa + erat + sodales + justo, + ac + rhoncus + elit + purus + eget + massa. + Donec + velit + est, + lobortis + quis, + vulputate + sit + amet, + molestie + id, + magna. + Vivamus + consequat, + felis + id + pulvinar + ullamcorper, + nunc + erat + id + sapien, + id + faucibus + sapien + odio + vel + pede.

+
+
+

Section 7

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+
+
+

Section 8

+

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo. Quisque sit amet est et sapien ullamcorper pharetra. Vestibulum erat wisi, condimentum sed, commodo vitae, ornare sit amet, wisi.

+
+
+

Section 9

+

Curabitur bibendum, erat id consequat consequat, odio + urna + suscipit + sapien, + nec + sollicitudin + nisl + erat + vel + enim. + Maecenas + viverra + condimentum + erat. + Aenean + porttitor, + mauris + eget + aliquam + dictum, + massa + erat + sodales + justo, + ac + rhoncus + elit + purus + eget + massa. + Donec + velit + est, + lobortis + quis, + vulputate + sit + amet, + molestie + id, + magna. + Vivamus + consequat, +

+ + \ No newline at end of file diff --git a/profiling/pulse_profiler_server/data/payload.json b/profiling/pulse_profiler_server/data/payload.json new file mode 100644 index 0000000..d871d41 --- /dev/null +++ b/profiling/pulse_profiler_server/data/payload.json @@ -0,0 +1,59 @@ +{ + "data": [ + { "id": 1, "name": "Item 1", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 2, "name": "Item 2", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 3, "name": "Item 3", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 4, "name": "Item 4", "value": 100.2, "active": true, "tags": [] }, + { "id": 5, "name": "Item 5", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 6, "name": "Item 6", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 7, "name": "Item 7", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 8, "name": "Item 8", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 9, "name": "Item 9", "value": 100.2, "active": true, "tags": [] }, + { "id": 10, "name": "Item 10", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 11, "name": "Item 11", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 12, "name": "Item 12", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 13, "name": "Item 13", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 14, "name": "Item 14", "value": 100.2, "active": true, "tags": [] }, + { "id": 15, "name": "Item 15", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 16, "name": "Item 16", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 17, "name": "Item 17", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 18, "name": "Item 18", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 19, "name": "V20", "value": 100.2, "active": true, "tags": [] }, + { "id": 20, "name": "Item 20", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 21, "name": "Item 21", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 22, "name": "Item 22", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 23, "name": "Item 23", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 24, "name": "Item 24", "value": 100.2, "active": true, "tags": [] }, + { "id": 25, "name": "Item 25", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 26, "name": "Item 26", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 27, "name": "Item 27", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 28, "name": "Item 28", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 29, "name": "Item 29", "value": 100.2, "active": true, "tags": [] }, + { "id": 30, "name": "Item 30", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 31, "name": "Item 31", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 32, "name": "Item 32", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 33, "name": "Item 33", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 34, "name": "Item 34", "value": 100.2, "active": true, "tags": [] }, + { "id": 35, "name": "Item 35", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 36, "name": "Item 36", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 37, "name": "Item 37", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 38, "name": "Item 38", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 39, "name": "Item 39", "value": 100.2, "active": true, "tags": [] }, + { "id": 40, "name": "Item 40", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 41, "name": "Item 41", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 42, "name": "Item 42", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 43, "name": "Item 43", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 44, "name": "Item 44", "value": 100.2, "active": true, "tags": [] }, + { "id": 45, "name": "Item 45", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 46, "name": "Item 46", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 47, "name": "Item 47", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 48, "name": "Item 48", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 49, "name": "Item 49", "value": 100.2, "active": true, "tags": [] }, + { "id": 50, "name": "Item 50", "value": 0.5, "active": false, "tags": ["f"] } + ], + "metadata": { + "count": 50, + "timestamp": "2025-11-04T10:42:00Z", + "source": "pgo-test-server" + } +} \ No newline at end of file diff --git a/profiling/pulse_profiler_server/src/main.rs b/profiling/pulse_profiler_server/src/main.rs new file mode 100644 index 0000000..673d33f --- /dev/null +++ b/profiling/pulse_profiler_server/src/main.rs @@ -0,0 +1,38 @@ +use axum::{ + http::HeaderName, + response::Html, // Use this for a proper text/html content type + routing::get, + Router, +}; +use std::net::SocketAddr; + +const JSON_PAYLOAD: &str = include_str!("../data/payload.json"); + +const HTML_PAYLOAD: &str = include_str!("../data/payload.html"); + +#[tokio::main] +async fn main() { + let app = Router::new() + .route("/", get(root_handler)) + .route("/html", get(html_handler)) + .route("/json", get(json_handler)); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + println!("Test server with HTML/JSON endpoints listening on {addr}"); + + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} + +async fn root_handler() -> &'static str { + return "Hello!"; +} + +async fn html_handler() -> Html<&'static str> { + return Html(HTML_PAYLOAD); +} + +async fn json_handler() -> ([(HeaderName, &'static str); 1], &'static str) { + let headers = [(HeaderName::from_static("content-type"), "application/json")]; + return (headers, JSON_PAYLOAD); +} \ No newline at end of file From 9011993d7a2bf592f6c3c00c5a0774eecbe02a5d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 4 Nov 2025 15:34:57 +0200 Subject: [PATCH 008/105] reconfigured semaphore --- src/Pulse/Core/Pulse.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 563e90c..dc6b349 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -18,7 +18,11 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); - using var semaphore = new SemaphoreSlim(Math.Max(1, parameters.Connections)); + // If connections is not modified it will be set to the number of requests + // so that all requests are sent in parallel by default. + int concurrencyLevel = Math.Max(1, parameters.Connections); + + using var semaphore = new SemaphoreSlim(concurrencyLevel, concurrencyLevel); var tasks = new Task[parameters.Requests]; From 4a5b80799d480159effa81f4b803fa0b94ea9a3a Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 18:19:15 +0200 Subject: [PATCH 009/105] server #2 --- profiling/pulse_profiler_server/Cargo.lock | 10 ++++++ profiling/pulse_profiler_server/Cargo.toml | 2 +- profiling/pulse_profiler_server/src/main.rs | 35 ++++++++++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/profiling/pulse_profiler_server/Cargo.lock b/profiling/pulse_profiler_server/Cargo.lock index cd7bcfb..211c599 100644 --- a/profiling/pulse_profiler_server/Cargo.lock +++ b/profiling/pulse_profiler_server/Cargo.lock @@ -365,6 +365,15 @@ dependencies = [ "serde", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -407,6 +416,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/profiling/pulse_profiler_server/Cargo.toml b/profiling/pulse_profiler_server/Cargo.toml index bf3c4fb..2ade359 100644 --- a/profiling/pulse_profiler_server/Cargo.toml +++ b/profiling/pulse_profiler_server/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" [dependencies] axum = "0.8.6" -tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "signal"] } [profile.release] opt-level = 3 diff --git a/profiling/pulse_profiler_server/src/main.rs b/profiling/pulse_profiler_server/src/main.rs index 673d33f..958f670 100644 --- a/profiling/pulse_profiler_server/src/main.rs +++ b/profiling/pulse_profiler_server/src/main.rs @@ -1,9 +1,10 @@ use axum::{ + Router, http::HeaderName, response::Html, // Use this for a proper text/html content type routing::get, - Router, }; +use tokio::signal; use std::net::SocketAddr; const JSON_PAYLOAD: &str = include_str!("../data/payload.json"); @@ -17,11 +18,14 @@ async fn main() { .route("/html", get(html_handler)) .route("/json", get(json_handler)); - let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); + let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); println!("Test server with HTML/JSON endpoints listening on {addr}"); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); } async fn root_handler() -> &'static str { @@ -35,4 +39,27 @@ async fn html_handler() -> Html<&'static str> { async fn json_handler() -> ([(HeaderName, &'static str); 1], &'static str) { let headers = [(HeaderName::from_static("content-type"), "application/json")]; return (headers, JSON_PAYLOAD); -} \ No newline at end of file +} + +// A future that completes when a shutdown signal is received. +async fn shutdown_signal() { + // Wait for the (Ctrl+C) signal + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + // Wait for a termination signal (on Unix) + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + // On Windows, we only need to listen for Ctrl+C + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + // Wait for either signal + tokio::select! { _ = ctrl_c => { println!("\nCtrl+C received, shutting down...") }, _ = terminate => { println!("\nTerminate signal received, shutting down...") }, } +} From df364efd54c007aac364369d7168224ad18d6956 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 19:58:44 +0200 Subject: [PATCH 010/105] Preserve command method info to workaround trimming --- src/Pulse/Program.cs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 4c2bf72..6925c71 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,17 +1,24 @@ -using ConsoleAppFramework; +using System.Diagnostics.CodeAnalysis; + +using ConsoleAppFramework; using Pulse.Core; -ConsoleApp.Version = Commands.VERSION; +internal partial class Program { + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(Commands))] + private static async Task Main(string[] args) { + ConsoleApp.Version = Commands.VERSION; -var app = ConsoleApp.Create(); + var app = ConsoleApp.Create(); -app.UseFilter(); + app.UseFilter(); -app.Add("", Commands.Root); -app.Add("get-sample", Commands.GetSample); -app.Add("get-schema", Commands.GetSchema); -app.Add("check-for-updates", Commands.CheckForUpdates); -app.Add("terms-of-use", Commands.TermsOfUse); + app.Add("", Commands.Root); + app.Add("get-sample", Commands.GetSample); + app.Add("get-schema", Commands.GetSchema); + app.Add("check-for-updates", Commands.CheckForUpdates); + app.Add("terms-of-use", Commands.TermsOfUse); -await app.RunAsync(args); \ No newline at end of file + await app.RunAsync(args); + } +} \ No newline at end of file From e3372737c8ad3a9faf0ef2e0777e9418a8c397e5 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 20:17:28 +0200 Subject: [PATCH 011/105] Fix issue with cancellation --- src/Pulse/Core/Pulse.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index dc6b349..96b3cc6 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -41,7 +41,10 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD }, cancellationToken); } - await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false); + // Task.WhenAll here should not use the cancellation token + // If it would, left over tasks could try to access an already disposed semaphore + // Causing an exception + await Task.WhenAll(tasks).ConfigureAwait(false); var result = monitor.ClearAndReturn(); From f18e98ac4b174be143a4757e4a080f698d74f5f3 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 20:27:08 +0200 Subject: [PATCH 012/105] Rename file to proper symbol name --- .../Core/{ExceptionHandler.cs => GlobalExceptionHandler.cs} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename src/Pulse/Core/{ExceptionHandler.cs => GlobalExceptionHandler.cs} (89%) diff --git a/src/Pulse/Core/ExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs similarity index 89% rename from src/Pulse/Core/ExceptionHandler.cs rename to src/Pulse/Core/GlobalExceptionHandler.cs index 9135e2b..0700f36 100644 --- a/src/Pulse/Core/ExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -15,7 +15,8 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo Environment.ExitCode = 1; } catch (Exception e) { ClearFrom(startLine); - WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at dusrdev@gmail.com and provide the following:"); + WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: https://dusrdev.github.io"); + WriteLine(OutputPipe.Error, $"{Red}and provide the following details:"); NewLine(OutputPipe.Error); Helper.PrintException(StrippedException.FromException(e)); Environment.ExitCode = 1; From 62914e03c9209abe3a2bc50d2e004807d549d3a8 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 20:27:27 +0200 Subject: [PATCH 013/105] Paths should not be followed by dot to enable linking by shell --- src/Pulse/Core/Commands.cs | 6 ++++-- src/Pulse/Core/PulseSummary.cs | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index b6dfa50..a614d76 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -62,8 +62,10 @@ public static async Task Root([Argument] string requestFile, OutputFolder = output }; - if (!InputJsonContext.TryGetRequestDetailsFromFile(Path.GetFullPath(requestFile), out var requestDetails)) { - WriteLine(OutputPipe.Error, $"Failed to retrieve and parse request file from {Yellow}{requestFile}"); + var requestFilePath = Path.GetFullPath(requestFile); + + if (!InputJsonContext.TryGetRequestDetailsFromFile(requestFilePath, out var requestDetails)) { + WriteLine(OutputPipe.Error, $"Failed to retrieve and parse request file from {Yellow}{requestFilePath}"); return 1; } ArgumentNullException.ThrowIfNull(requestDetails); diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 3af32c4..8f93d74 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -254,7 +254,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe if (count is 1) { await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token); - WriteLine($"{Cyan}1{Default} unique response exported to {Yellow}{parameters.OutputFolder}{Default}."); + WriteLine($"{Cyan}1{Default} unique response exported to {Yellow}{directory}"); return; } @@ -265,6 +265,6 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn)); - WriteLine($"{Cyan}{count}{Default} unique responses exported to {Yellow}{parameters.OutputFolder}{Default}."); + WriteLine($"{Cyan}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); } } \ No newline at end of file From 937b0dcd3fc14e22e192e4215faff7d7e824be5d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 20:34:10 +0200 Subject: [PATCH 014/105] Fix colored inconsistency in summary --- src/Pulse/Core/PulseSummary.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 8f93d74..f7d67cf 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -65,20 +65,18 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( Func getSize = Utils.Strings.FormatBytes; double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; - Overwrite(() => { - WriteLine($"Cross referencing results... {Green}done!"); - }, 1, OutputPipe.Error); + // Clear "cross referencing results..." ClearNextLines(1, OutputPipe.Error); WriteLine($"Request count: {Yellow}{completed}"); WriteLine($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); WriteLine($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); WriteLine($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); - WriteLine($"Latency: Min: {Cyan}{latencySummary.Min:0.##}ms{Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{Default}, Max: {Red}{latencySummary.Max:0.##}ms"); + WriteLine($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{Default}, Max: {Red}{latencySummary.Max:0.##}ms"); if (latencySummary.Removed != 0) { WriteLine($" (Removed {latencySummary.Removed} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); } - WriteLine($"Content Size: Min: {Cyan}{getSize(sizeSummary.Min)}{Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{Default}, Max: {Red}{getSize(sizeSummary.Max)}"); + WriteLine($"Content Size: Min: {Green}{getSize(sizeSummary.Min)}{Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{Default}, Max: {Red}{getSize(sizeSummary.Max)}"); WriteLine($"Total throughput: {Yellow}{getSize(throughput)}/s"); WriteLine($"Status codes:"); foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { @@ -111,8 +109,8 @@ public static (bool exportRequired, HashSet uniqueRequests) SummarizeS } else { WriteLine($"Success: {Red}false"); } - WriteLine($"Latency: {Cyan}{duration:0.##}ms"); - WriteLine($"Content Size: {Cyan}{Utils.Strings.FormatBytes(result.ContentLength)}"); + WriteLine($"Latency: {Green}{duration:0.##}ms"); + WriteLine($"Content Size: {Green}{Utils.Strings.FormatBytes(result.ContentLength)}"); if (statusCode is 0) { WriteLine($"Status code: {Red}0 [Exception]"); } else { @@ -254,7 +252,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe if (count is 1) { await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token); - WriteLine($"{Cyan}1{Default} unique response exported to {Yellow}{directory}"); + WriteLine($"{Green}1{Default} unique response exported to {Yellow}{directory}"); return; } @@ -265,6 +263,6 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn)); - WriteLine($"{Cyan}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); + WriteLine($"{Green}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); } } \ No newline at end of file From b33728b5cb674d6710d0f3ad1b1b384c559263d7 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 20:37:51 +0200 Subject: [PATCH 015/105] Added missing props --- src/Pulse/Core/Commands.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index a614d76..b72d4f4 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -183,6 +183,8 @@ internal static void PrintConfiguration(Parameters parameters, RequestDetails re // Options WriteLine($"{headerColor}Options:"); WriteLine($"{property} Request count: {value}{parameters.Requests}"); + WriteLine($"{property} Concurrent connections: {value}{parameters.Connections}"); + WriteLine($"{property} Delay: {value}{parameters.DelayInMs}ms"); WriteLine($"{property} Timeout: {value}{parameters.TimeoutInMs}"); WriteLine($"{property} Export Raw: {value}{parameters.ExportRaw}"); WriteLine($"{property} Format JSON: {value}{parameters.FormatJson}"); @@ -220,4 +222,4 @@ internal static void PrintConfiguration(Parameters parameters, RequestDetails re WriteLine($"{property} Password: {value}{requestDetails.Proxy.Password}"); WriteLine($"{property} Ignore SSL: {value}{requestDetails.Proxy.IgnoreSSL}"); } -} \ No newline at end of file +} From 61e40194b54f8cc0954d4e5b3d9582a9d6213b48 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 21:00:40 +0200 Subject: [PATCH 016/105] Fix leaking output to summary in verbose mode --- src/Pulse/Core/PulseSummary.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index f7d67cf..38095bf 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -34,7 +34,7 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( int peakConcurrentConnections = 0; Overwrite(() => { - WriteLine($"Cross referencing results..."); + Write(OutputPipe.Error, $"Cross referencing results..."); }, 1, OutputPipe.Error); foreach (var result in pulseResult.Results) { From 7c02cf984b82d69572db5253ebe1ac9ec8c15b4e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 5 Nov 2025 21:06:02 +0200 Subject: [PATCH 017/105] Added color to outliers --- src/Pulse/Core/PulseSummary.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 38095bf..91e953a 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -74,7 +74,7 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( WriteLine($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); WriteLine($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{Default}, Max: {Red}{latencySummary.Max:0.##}ms"); if (latencySummary.Removed != 0) { - WriteLine($" (Removed {latencySummary.Removed} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); + WriteLine($" (Removed {DarkYellow}{latencySummary.Removed}{Default} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); } WriteLine($"Content Size: Min: {Green}{getSize(sizeSummary.Min)}{Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{Default}, Max: {Red}{getSize(sizeSummary.Max)}"); WriteLine($"Total throughput: {Yellow}{getSize(throughput)}/s"); From d6c34d8f5f284b3f42efed379b7ca0d226c9d322 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Fri, 7 Nov 2025 11:59:56 +0200 Subject: [PATCH 018/105] Fixed trimming on ConsoleAppFramework side and updated version --- src/Pulse/Program.cs | 27 ++++++++++----------------- src/Pulse/Pulse.csproj | 2 +- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 6925c71..744588c 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,24 +1,17 @@ -using System.Diagnostics.CodeAnalysis; - -using ConsoleAppFramework; +using ConsoleAppFramework; using Pulse.Core; -internal partial class Program { - [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(Commands))] - private static async Task Main(string[] args) { - ConsoleApp.Version = Commands.VERSION; +ConsoleApp.Version = Commands.VERSION; - var app = ConsoleApp.Create(); +var app = ConsoleApp.Create(); - app.UseFilter(); +app.UseFilter(); - app.Add("", Commands.Root); - app.Add("get-sample", Commands.GetSample); - app.Add("get-schema", Commands.GetSchema); - app.Add("check-for-updates", Commands.CheckForUpdates); - app.Add("terms-of-use", Commands.TermsOfUse); +app.Add("", Commands.Root); +app.Add("get-sample", Commands.GetSample); +app.Add("get-schema", Commands.GetSchema); +app.Add("check-for-updates", Commands.CheckForUpdates); +app.Add("terms-of-use", Commands.TermsOfUse); - await app.RunAsync(args); - } -} \ No newline at end of file +await app.RunAsync(args); diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 77c395c..6f96798 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -24,7 +24,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all From 88ab3709d66861122a04f6f7df11fc34c2910926 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 8 Nov 2025 16:26:24 +0200 Subject: [PATCH 019/105] Improve ETA calculation in pulse monitor --- src/Pulse/Core/Helper.cs | 16 +++++++++++++++- src/Pulse/Core/PulseHttpClientFactory.cs | 1 - src/Pulse/Core/PulseMonitor.cs | 10 +++++----- src/Pulse/Core/Response.cs | 1 - 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index 4c2285c..d9bcb26 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -1,5 +1,5 @@ using System.Net; -using System.Runtime.CompilerServices; +using System.Numerics; using Pulse.Configuration; @@ -9,6 +9,20 @@ namespace Pulse.Core; /// Helper class /// public static class Helper { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static double Percentage(T current, T total) where T : INumberBase { + return double.CreateChecked(current / total); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TimeSpan GetETA(double percentage, TimeSpan elapsed) { + if (percentage <= 0) return TimeSpan.MaxValue; + if (percentage >= 1) return TimeSpan.Zero; + var rem = (1 - percentage) / percentage; + return rem * elapsed; + } + + /// /// Returns a text color based on percentage /// diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index 3e9733e..64c1ee1 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Threading; using Sharpify; diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 0caaad5..7dd4fdf 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -36,7 +36,7 @@ public sealed class PulseMonitor : IPulseMonitor { // 5: 5xx private readonly PaddedULong[] _stats = new PaddedULong[6]; private readonly RequestExecutionContext _requestExecutionContext; - private readonly int _requestCount; + private readonly ulong _requestCount; private readonly bool _saveContent; private readonly CancellationToken _cancellationToken; private readonly HttpClient _httpClient; @@ -49,7 +49,7 @@ public sealed class PulseMonitor : IPulseMonitor { /// public PulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { _results = new ConcurrentStack(); - _requestCount = parameters.Requests; + _requestCount = (ulong)parameters.Requests; _saveContent = parameters.Export; _cancellationToken = parameters.CancellationToken; _httpClient = client; @@ -79,8 +79,8 @@ public async Task SendAsync(int requestId) { [MethodImpl(MethodImplOptions.AggressiveInlining)] private void PrintMetrics() { lock (_lock) { - var elapsed = Stopwatch.GetElapsedTime(_start).TotalMilliseconds; - var eta = TimeSpan.FromMilliseconds(elapsed / _responses.Value * (_requestCount - (int)_responses.Value)); + var percentage = Helper.Percentage(_responses.Value, _requestCount); + var eta = Helper.GetETA(percentage, Stopwatch.GetElapsedTime(_start)); double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); var stats = new Stats { @@ -103,7 +103,7 @@ private readonly ref struct Stats { public required PaddedULong[] StatusCodes { get; init; } public required TimeSpan ETA { get; init; } public required double SuccessRate { get; init; } - public required int RequestCount { get; init; } + public required ulong RequestCount { get; init; } } /// diff --git a/src/Pulse/Core/Response.cs b/src/Pulse/Core/Response.cs index a261118..6539243 100644 --- a/src/Pulse/Core/Response.cs +++ b/src/Pulse/Core/Response.cs @@ -1,4 +1,3 @@ -using System; using System.Net; using Pulse.Configuration; From 76c4c89649982eeff3770de51ff84e7b6590fa61 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 11:15:22 +0200 Subject: [PATCH 020/105] Migrated to slnx --- Pulse.sln | 22 ---------------------- Pulse.slnx | 4 ++++ 2 files changed, 4 insertions(+), 22 deletions(-) delete mode 100644 Pulse.sln create mode 100644 Pulse.slnx diff --git a/Pulse.sln b/Pulse.sln deleted file mode 100644 index 84f0f71..0000000 --- a/Pulse.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pulse", "src\Pulse\Pulse.csproj", "{5DDF256A-024B-4FE1-BCE0-01393F4B1043}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pulse.Tests.Unit", "tests\Pulse.Tests.Unit\Pulse.Tests.Unit.csproj", "{A1811053-0F3C-49E4-95A0-1FD1CCE2835A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5DDF256A-024B-4FE1-BCE0-01393F4B1043}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5DDF256A-024B-4FE1-BCE0-01393F4B1043}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5DDF256A-024B-4FE1-BCE0-01393F4B1043}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5DDF256A-024B-4FE1-BCE0-01393F4B1043}.Release|Any CPU.Build.0 = Release|Any CPU - {A1811053-0F3C-49E4-95A0-1FD1CCE2835A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A1811053-0F3C-49E4-95A0-1FD1CCE2835A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A1811053-0F3C-49E4-95A0-1FD1CCE2835A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A1811053-0F3C-49E4-95A0-1FD1CCE2835A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Pulse.slnx b/Pulse.slnx new file mode 100644 index 0000000..0d234d9 --- /dev/null +++ b/Pulse.slnx @@ -0,0 +1,4 @@ + + + + From 49fdc573e1037db7c4b5e37f8caab3eb5000ed60 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 11:15:40 +0200 Subject: [PATCH 021/105] Address warnings --- src/Pulse/Configuration/DefaultJsonContext.cs | 3 +- src/Pulse/Configuration/InputJsonContext.cs | 2 +- src/Pulse/Configuration/Parameters.cs | 6 +- src/Pulse/Configuration/StrippedException.cs | 2 +- src/Pulse/Core/Commands.cs | 12 +-- src/Pulse/Core/Exporter.cs | 12 ++- src/Pulse/Core/GlobalExceptionHandler.cs | 8 +- src/Pulse/Core/Helper.cs | 4 +- src/Pulse/Core/IPulseMonitor.cs | 6 +- src/Pulse/Core/Pulse.cs | 4 +- src/Pulse/Core/PulseHttpClientFactory.cs | 6 +- src/Pulse/Core/PulseResult.cs | 2 +- src/Pulse/Core/PulseSummary.cs | 6 +- src/Pulse/Core/RawFailure.cs | 2 +- src/Pulse/Core/ReleaseInfo.cs | 2 +- src/Pulse/Core/RequestDetails.cs | 8 +- src/Pulse/Core/Response.cs | 4 +- src/Pulse/Core/VerbosePulseMonitor.cs | 4 +- src/Pulse/Program.cs | 2 +- src/Pulse/Pulse.csproj | 73 ++++++++++--------- 20 files changed, 92 insertions(+), 76 deletions(-) diff --git a/src/Pulse/Configuration/DefaultJsonContext.cs b/src/Pulse/Configuration/DefaultJsonContext.cs index 6e3f208..4d69d64 100644 --- a/src/Pulse/Configuration/DefaultJsonContext.cs +++ b/src/Pulse/Configuration/DefaultJsonContext.cs @@ -18,11 +18,12 @@ namespace Pulse.Configuration; [JsonSerializable(typeof(RawFailure))] [JsonSerializable(typeof(StrippedException))] [JsonSerializable(typeof(ReleaseInfo))] -public partial class DefaultJsonContext : JsonSerializerContext { +internal partial class DefaultJsonContext : JsonSerializerContext { /// /// Deserializes the version from the release info JSON /// /// + /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryDeserializeVersion(ReadOnlySpan releaseInfoJson, out Version? version) { diff --git a/src/Pulse/Configuration/InputJsonContext.cs b/src/Pulse/Configuration/InputJsonContext.cs index 599e7dd..a35bb53 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -17,7 +17,7 @@ namespace Pulse.Configuration; UseStringEnumConverter = true)] [JsonSerializable(typeof(RequestDetails))] [JsonSerializable(typeof(JsonElement))] -public partial class InputJsonContext : JsonSerializerContext { +internal partial class InputJsonContext : JsonSerializerContext { /// /// Try to get request details from file /// diff --git a/src/Pulse/Configuration/Parameters.cs b/src/Pulse/Configuration/Parameters.cs index 62405f4..7d1d3e6 100644 --- a/src/Pulse/Configuration/Parameters.cs +++ b/src/Pulse/Configuration/Parameters.cs @@ -3,7 +3,7 @@ namespace Pulse.Configuration; /// /// Execution parameters /// -public record ParametersBase { +internal record ParametersBase { /// /// Sets the number of requests (default = 100) /// @@ -17,7 +17,7 @@ public record ParametersBase { /// /// The delay between requests in milliseconds /// - public int DelayInMs { get; set; } = 0; + public int DelayInMs { get; set; } /// /// Sets the maximum connections @@ -63,7 +63,7 @@ public record ParametersBase { /// /// Execution parameters /// -public sealed record Parameters : ParametersBase { +internal sealed record Parameters : ParametersBase { /// /// Application-wide cancellation token /// diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Configuration/StrippedException.cs index b2a8938..220667f 100644 --- a/src/Pulse/Configuration/StrippedException.cs +++ b/src/Pulse/Configuration/StrippedException.cs @@ -7,7 +7,7 @@ namespace Pulse.Configuration; /// /// An exception only containing the type, message and stack trace /// -public sealed record StrippedException { +internal sealed record StrippedException { public static readonly StrippedException Default = new(); /// diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index b72d4f4..bd11429 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -11,7 +11,7 @@ namespace Pulse.Core; /// /// Commands /// -public static class Commands { +internal static class Commands { public const string VERSION = "2.0.0.0"; /// @@ -81,7 +81,7 @@ public static async Task Root([Argument] string requestFile, } WriteLine($"{Helper.GetMethodBasedColor(requestDetails.Request.Method.Method)}{requestDetails.Request.Method.Method}{Default} => {requestDetails.Request.Url}"); - await Pulse.RunAsync(@params, requestDetails); + await Pulse.RunAsync(@params, requestDetails).ConfigureAwait(false); return 0; } @@ -95,9 +95,9 @@ public static async Task CheckForUpdates(CancellationToken ct = default) { client.DefaultRequestHeaders.Add("User-Agent", "C# App"); client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); using var message = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/dusrdev/Pulse/releases/latest"); - using var response = await client.SendAsync(message, ct); + using var response = await client.SendAsync(message, ct).ConfigureAwait(false); if (response.IsSuccessStatusCode) { - var json = await response.Content.ReadAsStringAsync(ct); + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!DefaultJsonContext.TryDeserializeVersion(json, out var remoteVersion)) { WriteLine(OutputPipe.Error, $"Failed to retrieve version from remote."); return 1; @@ -150,7 +150,7 @@ public static async Task GetSchema(string? directory = null, CancellationTo TreatNullObliviousAsNonNullable = true, }; var schema = InputJsonContext.Default.RequestDetails.GetJsonSchemaAsNode(options).ToString(); - await File.WriteAllTextAsync(path, schema, ct); + await File.WriteAllTextAsync(path, schema, ct).ConfigureAwait(false); WriteLine($"Schema generated at {Yellow}{path}"); return 0; } @@ -165,7 +165,7 @@ public static async Task GetSample(string? directory = null, CancellationTo directory ??= Directory.GetCurrentDirectory(); var path = Path.Join(directory, "sample.json"); var json = JsonSerializer.Serialize(new RequestDetails(), InputJsonContext.Default.RequestDetails); - await File.WriteAllTextAsync(path, json, ct); + await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); WriteLine($"Sample request generated at {Yellow}{path}"); return 0; } diff --git a/src/Pulse/Core/Exporter.cs b/src/Pulse/Core/Exporter.cs index 8664406..d337e73 100644 --- a/src/Pulse/Core/Exporter.cs +++ b/src/Pulse/Core/Exporter.cs @@ -6,7 +6,7 @@ namespace Pulse.Core; -public static class Exporter { +internal static class Exporter { private const string JsonExtension = "json"; private const string HtmlExtension = "html"; @@ -42,6 +42,7 @@ internal static async Task ExportRawAsync(Response result, string path, bool for content = DefaultJsonContext.Serialize(failure); extension = JsonExtension; } else if (formatJson) { +#pragma warning disable CA1031 // Do not catch general exception types try { using var doc = JsonDocument.Parse(result.Content); var root = doc.RootElement; @@ -50,6 +51,7 @@ internal static async Task ExportRawAsync(Response result, string path, bool for } catch { content = result.Content; } +#pragma warning restore CA1031 // Do not catch general exception types extension = JsonExtension; } else { content = result.Content; @@ -59,7 +61,7 @@ internal static async Task ExportRawAsync(Response result, string path, bool for string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.{extension}"); - await File.WriteAllTextAsync(filename, content, token); + await File.WriteAllTextAsync(filename, content, token).ConfigureAwait(false); } internal static async Task ExportHtmlAsync(Response result, string path, bool formatJson = false, CancellationToken token = default) { @@ -87,7 +89,7 @@ internal static async Task ExportHtmlAsync(Response result, string path, bool fo } string filename = Path.Join(path, $"response-{result.Id}-status-code-{(int)statusCode}.html"); - string contentFrame = content == string.Empty ? + string contentFrame = content.Length == 0 ? """

Content: Empty...

@@ -296,7 +298,7 @@ tbody td {
"""; - await File.WriteAllTextAsync(filename, body, token); + await File.WriteAllTextAsync(filename, body, token).ConfigureAwait(false); } /// @@ -344,11 +346,13 @@ internal static string ToHtmlTable(IEnumerable /// Helper class /// -public static class Helper { +internal static class Helper { [MethodImpl(MethodImplOptions.AggressiveInlining)] public static double Percentage(T current, T total) where T : INumberBase { return double.CreateChecked(current / total); @@ -77,7 +77,9 @@ public static Color GetMethodBasedColor(string method) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy proxy) { if (proxy.IgnoreSSL) { +#pragma warning disable CA5359 // Do Not Disable Certificate Validation handler.SslOptions.RemoteCertificateValidationCallback = static (_, _, _, _) => true; +#pragma warning restore CA5359 // Do Not Disable Certificate Validation } } diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index 18d91ea..740c0a6 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -9,7 +9,7 @@ namespace Pulse.Core; /// /// IPulseMonitor defines the traits for the wrappers that handles display of metrics and cross-thread data collection /// -public interface IPulseMonitor { +internal interface IPulseMonitor { /// /// Creates a new pulse monitor according the verbosity setting /// @@ -62,7 +62,7 @@ public async Task SendRequest(int id, Request requestRecipe, HttpClien HttpResponseMessage? response = null; try { currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); - response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); } catch (TimeoutException ex) { exception = StrippedException.FromException(ex); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { @@ -93,7 +93,7 @@ public async Task SendRequest(int id, Request requestRecipe, HttpClien contentLength = length.Value; } if (saveContent) { - content = await r.Content.ReadAsStringAsync(cancellationToken); + content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (contentLength == 0) { var charSet = r.Content.Headers.ContentType?.CharSet; var encoding = charSet is null diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 96b3cc6..30e8242 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -5,7 +5,7 @@ namespace Pulse.Core; /// /// Pulse runner /// -public static class Pulse { +internal static class Pulse { /// /// Runs the pulse according the specification requested in /// @@ -51,7 +51,7 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, result, requestDetails.Request.GetRequestLength()); if (exportRequired) { - await PulseSummary.ExportUniqueRequestsAsync(parameters, uniqueRequests, cancellationToken); + await PulseSummary.ExportUniqueRequestsAsync(parameters, uniqueRequests, cancellationToken).ConfigureAwait(false); } } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index 64c1ee1..a3cc853 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -7,7 +7,7 @@ namespace Pulse.Core; /// /// Http client factory /// -public static class PulseHttpClientFactory { +internal static class PulseHttpClientFactory { /// /// Creates an HttpClient with the specified /// @@ -15,9 +15,11 @@ public static class PulseHttpClientFactory { /// /// An HttpClient public static HttpClient Create(Proxy proxyDetails, int timeoutInMs) { +#pragma warning disable CA2000 // Dispose objects before losing scope SocketsHttpHandler handler = CreateHandler(proxyDetails); +#pragma warning restore CA2000 // Dispose objects before losing scope - var client = new HttpClient(handler) { + var client = new HttpClient(handler, true) { Timeout = timeoutInMs < 0 ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(timeoutInMs) diff --git a/src/Pulse/Core/PulseResult.cs b/src/Pulse/Core/PulseResult.cs index 5816ec6..9b9d26a 100644 --- a/src/Pulse/Core/PulseResult.cs +++ b/src/Pulse/Core/PulseResult.cs @@ -5,7 +5,7 @@ namespace Pulse.Core; /// /// Result of pulse (complete test) /// -public readonly ref struct PulseResult { +internal readonly ref struct PulseResult { /// /// Results of the individual requests /// diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 91e953a..256ed47 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -12,7 +12,7 @@ namespace Pulse.Core; /// /// Pulse summary handles outputs and experts post-pulse /// -public static class PulseSummary { +internal static class PulseSummary { /// /// Produces a summary, and saves unique requests if export is enabled. /// @@ -251,7 +251,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe Exporter.ClearFiles(directory); if (count is 1) { - await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token); + await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token).ConfigureAwait(false); WriteLine($"{Green}1{Default} unique response exported to {Yellow}{directory}"); return; } @@ -261,7 +261,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe CancellationToken = token }; - await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn)); + await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn).ConfigureAwait(false)).ConfigureAwait(false); WriteLine($"{Green}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); } diff --git a/src/Pulse/Core/RawFailure.cs b/src/Pulse/Core/RawFailure.cs index b254eb2..4a5eaeb 100644 --- a/src/Pulse/Core/RawFailure.cs +++ b/src/Pulse/Core/RawFailure.cs @@ -5,7 +5,7 @@ namespace Pulse.Core; /// /// Represents a serializable way to display non successful response information when using /// -public readonly struct RawFailure { +internal readonly struct RawFailure { public RawFailure() { Headers = []; StatusCode = 0; diff --git a/src/Pulse/Core/ReleaseInfo.cs b/src/Pulse/Core/ReleaseInfo.cs index 2d237cd..2db9f57 100644 --- a/src/Pulse/Core/ReleaseInfo.cs +++ b/src/Pulse/Core/ReleaseInfo.cs @@ -5,7 +5,7 @@ namespace Pulse.Core; /// /// Release information /// -public sealed class ReleaseInfo { +internal sealed class ReleaseInfo { /// /// Version is taken from the release tag /// diff --git a/src/Pulse/Core/RequestDetails.cs b/src/Pulse/Core/RequestDetails.cs index 69ab0d9..ba9f37f 100644 --- a/src/Pulse/Core/RequestDetails.cs +++ b/src/Pulse/Core/RequestDetails.cs @@ -8,7 +8,7 @@ namespace Pulse.Core; /// /// Request details /// -public class RequestDetails { +internal class RequestDetails { /// /// Proxy configuration /// @@ -23,7 +23,7 @@ public class RequestDetails { /// /// Proxy configuration /// -public class Proxy { +internal class Proxy { /// /// Don't use proxy /// @@ -53,7 +53,7 @@ public class Proxy { /// /// Request configuration /// -public class Request { +internal class Request { public const string DefaultUrl = "https://ipinfo.io/geo"; /// @@ -135,7 +135,7 @@ public long GetRequestLength() { /// /// Request content /// -public readonly struct Content { +internal readonly struct Content { [JsonConstructor] public Content() { ContentType = string.Empty; diff --git a/src/Pulse/Core/Response.cs b/src/Pulse/Core/Response.cs index 6539243..d9cc66f 100644 --- a/src/Pulse/Core/Response.cs +++ b/src/Pulse/Core/Response.cs @@ -7,7 +7,7 @@ namespace Pulse.Core; /// /// The model used for response /// -public readonly record struct Response { +internal readonly record struct Response { /// /// The id of the request /// @@ -52,7 +52,7 @@ public readonly record struct Response { /// /// Request comparer to be used in HashSets /// -public sealed class ResponseComparer : IEqualityComparer { +internal sealed class ResponseComparer : IEqualityComparer { private readonly Parameters _parameters; public ResponseComparer(Parameters parameters) { diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 359eaaa..847bccc 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -11,7 +11,7 @@ namespace Pulse.Core; /// /// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// -public sealed class VerbosePulseMonitor : IPulseMonitor { +internal sealed class VerbosePulseMonitor : IPulseMonitor { /// /// Holds the results of all the requests /// @@ -58,7 +58,7 @@ public async Task SendAsync(int requestId) { lock (_lock) { WriteLine(OutputPipe.Error, $"{Yellow}--> {Default}Sent request: {Yellow}{requestId}"); } - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); + var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); Interlocked.Increment(ref _responses.Value); // Increment stats if (result.StatusCode is HttpStatusCode.OK) { diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 744588c..d769306 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -14,4 +14,4 @@ app.Add("check-for-updates", Commands.CheckForUpdates); app.Add("terms-of-use", Commands.TermsOfUse); -await app.RunAsync(args); +await app.RunAsync(args).ConfigureAwait(false); diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 6f96798..c9534b9 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -1,41 +1,44 @@  - - Exe - net9.0 - enable - enable - full - Speed - false - false - true - true - true - false - false - 0 - true - false - 2.0.0.0 - true - https://github.com/dusrdev/Pulse - git - + + Exe + net9.0 + enable + enable + full + Speed + false + false + All + true + true + true + false + true + false + 0 + true + false + 2.0.0.0 + true + https://github.com/dusrdev/Pulse + git + true + - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + - - - <_Parameter1>Pulse.Tests.Unit - - + + + <_Parameter1>Pulse.Tests.Unit + + \ No newline at end of file From 0cda43f87bf491e2b0b798579b2500b240d5a067 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 11:15:58 +0200 Subject: [PATCH 022/105] Use channel to limit concurrent console printing --- src/Pulse/Core/PulseMonitor.cs | 90 ++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 37 deletions(-) diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 7dd4fdf..fd218f1 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Threading.Channels; using Pulse.Configuration; @@ -11,7 +12,7 @@ namespace Pulse.Core; /// /// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// -public sealed class PulseMonitor : IPulseMonitor { +internal sealed class PulseMonitor : IPulseMonitor { /// /// Holds the results of all the requests /// @@ -42,7 +43,11 @@ public sealed class PulseMonitor : IPulseMonitor { private readonly HttpClient _httpClient; private readonly Request _requestRecipe; - private readonly Lock _lock = new(); + private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { + SingleWriter = false, + SingleReader = true, + FullMode = BoundedChannelFullMode.DropWrite + }); /// /// Creates a new pulse monitor @@ -55,13 +60,27 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet _httpClient = client; _requestRecipe = requestRecipe; _requestExecutionContext = new RequestExecutionContext(); - PrintInitialMetrics(); _start = Stopwatch.GetTimestamp(); + + _ = _channel.Writer.TryWrite(new Stats { + Percentage = 0, + CurrentCount = _responses, + SuccessRate = 0, + ETA = TimeSpan.MaxValue, + RequestCount = _requestCount, + StatusCodes = _stats + }); + + _ = Task.Run(async () => { + await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { + PrintMetrics(stats); + } + }); } /// public async Task SendAsync(int requestId) { - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken); + var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); Interlocked.Increment(ref _responses.Value); // Increment stats @@ -69,7 +88,7 @@ public async Task SendAsync(int requestId) { Interlocked.Increment(ref _stats[index].Value); // Print metrics - PrintMetrics(); + await PushMetricsAsync().ConfigureAwait(false); _results.Push(result); } @@ -77,50 +96,47 @@ public async Task SendAsync(int requestId) { /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PrintMetrics() { - lock (_lock) { - var percentage = Helper.Percentage(_responses.Value, _requestCount); - var eta = Helper.GetETA(percentage, Stopwatch.GetElapsedTime(_start)); - double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); - - var stats = new Stats { - CurrentCount = _responses, - RequestCount = _requestCount, - StatusCodes = _stats, - ETA = eta, - SuccessRate = sr - }; - - Overwrite(stats, static s => { - WriteLine(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{Default}/{Yellow}{s.RequestCount}{Default}, SR: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{Default}%, ETA: {Yellow}{s.ETA:hr}"); - WriteLine(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{Default}, 2xx: {Green}{s.StatusCodes[2].Value}{Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{Default}, 4xx: {Red}{s.StatusCodes[4].Value}{Default}, 5xx: {Red}{s.StatusCodes[5].Value}{Default}, others: {Magenta}{s.StatusCodes[0].Value}"); - }, 2, OutputPipe.Error); - } + private async ValueTask PushMetricsAsync() { + var percentage = (double)_responses.Value / _requestCount; + var eta = Helper.GetETA(percentage, Stopwatch.GetElapsedTime(_start)); + double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); + + var stats = new Stats { + Percentage = percentage, + CurrentCount = _responses, + RequestCount = _requestCount, + StatusCodes = _stats, + ETA = eta, + SuccessRate = sr + }; + + await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void PrintMetrics(Stats stats) { + Overwrite(stats, static s => { + Write(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{Default}/{Yellow}{s.RequestCount}{Default} "); + ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green); + WriteLine(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{Default}%, Estimated time remaining: {Yellow}{s.ETA:hr}"); + WriteLine(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{Default}, 2xx: {Green}{s.StatusCodes[2].Value}{Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{Default}, 4xx: {Red}{s.StatusCodes[4].Value}{Default}, 5xx: {Red}{s.StatusCodes[5].Value}{Default}, others: {Magenta}{s.StatusCodes[0].Value}"); + }, 3, OutputPipe.Error); } - private readonly ref struct Stats { + private readonly struct Stats { public required PaddedULong CurrentCount { get; init; } public required PaddedULong[] StatusCodes { get; init; } + public required double Percentage { get; init; } public required TimeSpan ETA { get; init; } public required double SuccessRate { get; init; } public required ulong RequestCount { get; init; } } - /// - /// Prints the initial metrics to establish ui - /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void PrintInitialMetrics() { - Overwrite(_requestCount, static requests => { - WriteLine(OutputPipe.Error, $"Completed: {Yellow}0{Default}/{Yellow}{requests}{Default}, SR: {Red}0{Default}%, ETA: {Yellow}NaN"); - WriteLine(OutputPipe.Error, $"1xx: {White}0{Default}, 2xx: {Green}0{Default}, 3xx: {Yellow}0{Default}, 4xx: {Red}0{Default}, 5xx: {Red}0{Default}, others: {Magenta}0"); - }, 2, OutputPipe.Error); - } - /// public PulseResult ClearAndReturn() { // Clear after metrics - ClearNextLines(2, OutputPipe.Error); + _channel.Writer.Complete(); + ClearNextLines(3, OutputPipe.Error); return new() { Results = _results, From 31aa6dedb3dc67e6ea90db1be0f4893ea6f34864 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 11:29:05 +0200 Subject: [PATCH 023/105] Address dash board leaks to summary --- src/Pulse/Core/IPulseMonitor.cs | 2 +- src/Pulse/Core/Pulse.cs | 2 +- src/Pulse/Core/PulseMonitor.cs | 6 ++++-- src/Pulse/Core/PulseResult.cs | 2 +- src/Pulse/Core/VerbosePulseMonitor.cs | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index 740c0a6..327010e 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -32,7 +32,7 @@ public static IPulseMonitor Create(HttpClient client, Request requestRecipe, Par /// /// Run cleanup and return results /// - PulseResult ClearAndReturn(); + Task ClearAndReturnAsync(); /// /// Request execution context diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 30e8242..eaae126 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -46,7 +46,7 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD // Causing an exception await Task.WhenAll(tasks).ConfigureAwait(false); - var result = monitor.ClearAndReturn(); + var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, result, requestDetails.Request.GetRequestLength()); diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index fd218f1..ba3d1bb 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -42,6 +42,7 @@ internal sealed class PulseMonitor : IPulseMonitor { private readonly CancellationToken _cancellationToken; private readonly HttpClient _httpClient; private readonly Request _requestRecipe; + private readonly Task _printer; private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { SingleWriter = false, @@ -71,7 +72,7 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet StatusCodes = _stats }); - _ = Task.Run(async () => { + _printer = Task.Run(async () => { await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { PrintMetrics(stats); } @@ -133,9 +134,10 @@ private readonly struct Stats { } /// - public PulseResult ClearAndReturn() { + public async Task ClearAndReturnAsync() { // Clear after metrics _channel.Writer.Complete(); + await _printer.ConfigureAwait(false); ClearNextLines(3, OutputPipe.Error); return new() { diff --git a/src/Pulse/Core/PulseResult.cs b/src/Pulse/Core/PulseResult.cs index 9b9d26a..4393285 100644 --- a/src/Pulse/Core/PulseResult.cs +++ b/src/Pulse/Core/PulseResult.cs @@ -5,7 +5,7 @@ namespace Pulse.Core; /// /// Result of pulse (complete test) /// -internal readonly ref struct PulseResult { +internal readonly struct PulseResult { /// /// Results of the individual requests /// diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 847bccc..91695fe 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -72,13 +72,13 @@ public async Task SendAsync(int requestId) { } /// - public PulseResult ClearAndReturn() { + public Task ClearAndReturnAsync() { NewLine(OutputPipe.Error); - return new() { + return Task.FromResult(new PulseResult { Results = _results, SuccessRate = Math.Round((double)_successes.Value / _responses.Value * 100, 2), TotalDuration = Stopwatch.GetElapsedTime(_start) - }; + }); } } \ No newline at end of file From 7825e75499b1011feb7c668fd43e1f01d78407d3 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:04:41 +0200 Subject: [PATCH 024/105] Set langversion --- src/Pulse/Pulse.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index c9534b9..93d37ab 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -6,6 +6,7 @@ enable enable full + preview Speed false false From a140436a47a114cf1ceda89cbdc6163e59ad6fa1 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:04:54 +0200 Subject: [PATCH 025/105] Added state to track lines written --- src/Pulse/Core/ConsoleState.cs | 12 ++++++++++++ src/Pulse/Core/GlobalExceptionHandler.cs | 8 ++++---- src/Pulse/Core/PulseMonitor.cs | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 src/Pulse/Core/ConsoleState.cs diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs new file mode 100644 index 0000000..45faa85 --- /dev/null +++ b/src/Pulse/Core/ConsoleState.cs @@ -0,0 +1,12 @@ +namespace Pulse.Core; + +internal static class ConsoleState { + public static int LinesWritten { + get { + return field; + } + set { + Interlocked.Exchange(ref field, value); + } + } +} \ No newline at end of file diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index a2009cf..6802d7b 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -12,11 +12,11 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - ClearFrom(startLine); + ClearFrom(startLine, ConsoleState.LinesWritten); WriteLine(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); Environment.ExitCode = 1; } catch (Exception e) { - ClearFrom(startLine); + ClearFrom(startLine, ConsoleState.LinesWritten); WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: https://dusrdev.github.io"); WriteLine(OutputPipe.Error, $"{Red}and provide the following details:"); NewLine(OutputPipe.Error); @@ -24,8 +24,8 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo Environment.ExitCode = 1; } - static void ClearFrom(int start) { - int lines = GetCurrentLine() - start + 1; + static void ClearFrom(int start, int end) { + int lines = end + 1; GoToLine(start); ClearNextLines(lines, OutputPipe.Error); GoToLine(start); diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index ba3d1bb..807573b 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -72,6 +72,8 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet StatusCodes = _stats }); + ConsoleState.LinesWritten += 3; + _printer = Task.Run(async () => { await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { PrintMetrics(stats); From 58093d3f5dd0a49ab5c8e9da412d08597f6e0b29 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:16:25 +0200 Subject: [PATCH 026/105] Use console state to clear outputs --- src/Pulse/Core/GlobalExceptionHandler.cs | 8 ++++---- src/Pulse/Core/PulseSummary.cs | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index 6802d7b..3c25db7 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -12,11 +12,11 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - ClearFrom(startLine, ConsoleState.LinesWritten); + ClearFrom(startLine); WriteLine(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); Environment.ExitCode = 1; } catch (Exception e) { - ClearFrom(startLine, ConsoleState.LinesWritten); + ClearFrom(startLine); WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: https://dusrdev.github.io"); WriteLine(OutputPipe.Error, $"{Red}and provide the following details:"); NewLine(OutputPipe.Error); @@ -24,8 +24,8 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo Environment.ExitCode = 1; } - static void ClearFrom(int start, int end) { - int lines = end + 1; + static void ClearFrom(int start) { + int lines = Math.Max(GetCurrentLine(), ConsoleState.LinesWritten) + 1; GoToLine(start); ClearNextLines(lines, OutputPipe.Error); GoToLine(start); diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 256ed47..9165fa9 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -33,6 +33,7 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( long totalSize = 0; int peakConcurrentConnections = 0; + ConsoleState.LinesWritten += 1; Overwrite(() => { Write(OutputPipe.Error, $"Cross referencing results..."); }, 1, OutputPipe.Error); From 087cf7a3305e6ffbfd6a13feebca78977a50b664 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:32:46 +0200 Subject: [PATCH 027/105] - --- src/Pulse/Core/ConsoleState.cs | 2 +- src/Pulse/Core/GlobalExceptionHandler.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index 45faa85..30482c9 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -6,7 +6,7 @@ public static int LinesWritten { return field; } set { - Interlocked.Exchange(ref field, value); + Interlocked.Exchange(ref field, Math.Max(field, value)); } } } \ No newline at end of file diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index 3c25db7..dd1ce78 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -9,6 +9,7 @@ namespace Pulse.Core; internal sealed class GlobalExceptionHandler(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { int startLine = GetCurrentLine(); + ConsoleState.LinesWritten = startLine; try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { @@ -25,7 +26,8 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo } static void ClearFrom(int start) { - int lines = Math.Max(GetCurrentLine(), ConsoleState.LinesWritten) + 1; + int last = Math.Max(GetCurrentLine(), ConsoleState.LinesWritten); + int lines = Math.Max(1, last - start + 1); GoToLine(start); ClearNextLines(lines, OutputPipe.Error); GoToLine(start); From ed3510347e986fc090ea69b0d792e71ebd6cb348 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:39:42 +0200 Subject: [PATCH 028/105] Better tracking of lines written --- src/Pulse/Core/ConsoleState.cs | 33 +++++++++++++++++------- src/Pulse/Core/GlobalExceptionHandler.cs | 2 +- src/Pulse/Core/PulseMonitor.cs | 4 +-- src/Pulse/Core/PulseSummary.cs | 4 +-- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index 30482c9..38ef6ea 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -1,12 +1,27 @@ namespace Pulse.Core; internal static class ConsoleState { - public static int LinesWritten { - get { - return field; - } - set { - Interlocked.Exchange(ref field, Math.Max(field, value)); - } - } -} \ No newline at end of file + public static int LinesWritten { + get => field; + set => Interlocked.Exchange(ref field, value); + } + + public static void Reset(int startLine) { + LinesWritten = startLine; + } + + public static void ReportLinesFromCurrent(int lineCount) { + if (lineCount <= 0) { + return; + } + + int current = GetCurrentLine(); + int lastLine = current + lineCount - 1; + UpdateMax(lastLine); + } + + private static void UpdateMax(int candidate) { + if (LinesWritten >= candidate) return; + LinesWritten = candidate; + } +} diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index dd1ce78..ebb8851 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -9,7 +9,7 @@ namespace Pulse.Core; internal sealed class GlobalExceptionHandler(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { int startLine = GetCurrentLine(); - ConsoleState.LinesWritten = startLine; + ConsoleState.Reset(startLine); try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 807573b..fe3b070 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -72,7 +72,7 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet StatusCodes = _stats }); - ConsoleState.LinesWritten += 3; + ConsoleState.ReportLinesFromCurrent(3); _printer = Task.Run(async () => { await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { @@ -148,4 +148,4 @@ public async Task ClearAndReturnAsync() { TotalDuration = Stopwatch.GetElapsedTime(_start) }; } -} \ No newline at end of file +} diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 9165fa9..89d1da5 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -33,7 +33,7 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( long totalSize = 0; int peakConcurrentConnections = 0; - ConsoleState.LinesWritten += 1; + ConsoleState.ReportLinesFromCurrent(1); Overwrite(() => { Write(OutputPipe.Error, $"Cross referencing results..."); }, 1, OutputPipe.Error); @@ -266,4 +266,4 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe WriteLine($"{Green}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); } -} \ No newline at end of file +} From 9de3e14aaf1deed9787ac083e6a040f91a768190 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:44:33 +0200 Subject: [PATCH 029/105] Use lock --- src/Pulse/Core/ConsoleState.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index 38ef6ea..caf8416 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -1,13 +1,13 @@ namespace Pulse.Core; internal static class ConsoleState { - public static int LinesWritten { - get => field; - set => Interlocked.Exchange(ref field, value); - } + private static readonly Lock Lock = new(); + public static int LinesWritten { get; set; } public static void Reset(int startLine) { - LinesWritten = startLine; + lock (Lock) { + LinesWritten = startLine; + } } public static void ReportLinesFromCurrent(int lineCount) { @@ -21,7 +21,9 @@ public static void ReportLinesFromCurrent(int lineCount) { } private static void UpdateMax(int candidate) { - if (LinesWritten >= candidate) return; - LinesWritten = candidate; + lock (Lock) { + if (LinesWritten >= candidate) return; + LinesWritten = candidate; + } } } From a52aa35d98f03cd61674791a087f8c5256eb8231 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:49:33 +0200 Subject: [PATCH 030/105] Cleanup LinesWritten --- src/Pulse/Core/ConsoleState.cs | 40 ++++++++++++++++------------------ 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index caf8416..27a180b 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -1,29 +1,27 @@ namespace Pulse.Core; internal static class ConsoleState { - private static readonly Lock Lock = new(); - public static int LinesWritten { get; set; } + public static int LinesWritten { + get => field; + set => Interlocked.Exchange(ref field, value); + } - public static void Reset(int startLine) { - lock (Lock) { - LinesWritten = startLine; - } - } + public static void Reset(int startLine) { + LinesWritten = startLine; + } - public static void ReportLinesFromCurrent(int lineCount) { - if (lineCount <= 0) { - return; - } + public static void ReportLinesFromCurrent(int lineCount) { + if (lineCount <= 0) { + return; + } - int current = GetCurrentLine(); - int lastLine = current + lineCount - 1; - UpdateMax(lastLine); - } + int current = GetCurrentLine(); + int lastLine = current + lineCount - 1; + UpdateMax(lastLine); + } - private static void UpdateMax(int candidate) { - lock (Lock) { - if (LinesWritten >= candidate) return; - LinesWritten = candidate; - } - } + private static void UpdateMax(int candidate) { + if (LinesWritten >= candidate) return; + LinesWritten = candidate; + } } From fd82ec446dcb8d6879e0e183c12592b294c19bbf Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 11 Nov 2025 12:53:03 +0200 Subject: [PATCH 031/105] Hide cursor in dashboard --- src/Pulse/Core/PulseMonitor.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index fe3b070..1126327 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -72,6 +72,7 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet StatusCodes = _stats }); + System.Console.CursorVisible = false; ConsoleState.ReportLinesFromCurrent(3); _printer = Task.Run(async () => { @@ -141,6 +142,7 @@ public async Task ClearAndReturnAsync() { _channel.Writer.Complete(); await _printer.ConfigureAwait(false); ClearNextLines(3, OutputPipe.Error); + System.Console.CursorVisible = true; return new() { Results = _results, From 1e3ea8532c4d41e54a6471c38462b1c85a49ecc2 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 12 Nov 2025 19:47:21 +0200 Subject: [PATCH 032/105] Update to net10 --- src/Pulse/Pulse.csproj | 8 ++++---- tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 93d37ab..90905fb 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 enable enable full @@ -28,12 +28,12 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + diff --git a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj b/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj index 247d9ef..ec83dc8 100644 --- a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj +++ b/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj @@ -5,7 +5,7 @@ enable Exe Pulse.Tests.Unit - net9.0 + net10.0 true true From 6d048d5caed6d5a3f883dc708e99ab7e79b8a845 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 12 Nov 2025 20:06:05 +0200 Subject: [PATCH 033/105] Use workers instead of pure tasks --- src/Pulse/Core/Pulse.cs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index eaae126..edc7c0a 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -21,30 +21,29 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD // If connections is not modified it will be set to the number of requests // so that all requests are sent in parallel by default. int concurrencyLevel = Math.Max(1, parameters.Connections); + int totalRequests = parameters.Requests; + int workerCount = totalRequests == 0 ? 0 : Math.Min(concurrencyLevel, totalRequests); + + var workers = new Task[workerCount]; + int nextRequestId = 0; + + for (int i = 0; i < workers.Length; i++) { + workers[i] = Task.Run(async () => { + while (!cancellationToken.IsCancellationRequested) { + int requestId = Interlocked.Increment(ref nextRequestId); + if (requestId > totalRequests) { + break; + } - using var semaphore = new SemaphoreSlim(concurrencyLevel, concurrencyLevel); - - var tasks = new Task[parameters.Requests]; - - for (int i = 0; i < tasks.Length; i++) { - var requestId = i + 1; - tasks[i] = Task.Run(async () => { - await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - try { await monitor.SendAsync(requestId).ConfigureAwait(false); if (parameters.DelayInMs > 0) { await Task.Delay(parameters.DelayInMs, cancellationToken).ConfigureAwait(false); } - } finally { - semaphore.Release(); } }, cancellationToken); } - // Task.WhenAll here should not use the cancellation token - // If it would, left over tasks could try to access an already disposed semaphore - // Causing an exception - await Task.WhenAll(tasks).ConfigureAwait(false); + await Task.WhenAll(workers).ConfigureAwait(false); var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); @@ -54,4 +53,4 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD await PulseSummary.ExportUniqueRequestsAsync(parameters, uniqueRequests, cancellationToken).ConfigureAwait(false); } } -} \ No newline at end of file +} From 1951d8687c7f9ded270ced6643c8a2cf9b7087fe Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 15 Nov 2025 15:56:49 +0200 Subject: [PATCH 034/105] Upgrade to PrettyConsole5 --- src/Pulse/Core/Commands.cs | 84 ++++++++++++------------ src/Pulse/Core/ConsoleState.cs | 2 +- src/Pulse/Core/GlobalExceptionHandler.cs | 22 +++---- src/Pulse/Core/Helper.cs | 16 ++--- src/Pulse/Core/PulseMonitor.cs | 12 ++-- src/Pulse/Core/PulseSummary.cs | 54 +++++++-------- src/Pulse/Core/VerbosePulseMonitor.cs | 6 +- src/Pulse/GlobalUsings.cs | 3 +- src/Pulse/Pulse.csproj | 4 +- 9 files changed, 101 insertions(+), 102 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index bd11429..97fce31 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -65,7 +65,7 @@ public static async Task Root([Argument] string requestFile, var requestFilePath = Path.GetFullPath(requestFile); if (!InputJsonContext.TryGetRequestDetailsFromFile(requestFilePath, out var requestDetails)) { - WriteLine(OutputPipe.Error, $"Failed to retrieve and parse request file from {Yellow}{requestFilePath}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to retrieve and parse request file from {Markup.Underline}{Yellow}{requestFilePath}{Markup.ResetUnderline}"); return 1; } ArgumentNullException.ThrowIfNull(requestDetails); @@ -80,7 +80,7 @@ public static async Task Root([Argument] string requestFile, return 0; } - WriteLine($"{Helper.GetMethodBasedColor(requestDetails.Request.Method.Method)}{requestDetails.Request.Method.Method}{Default} => {requestDetails.Request.Url}"); + Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(requestDetails.Request.Method.Method)}{requestDetails.Request.Method.Method}{ConsoleColor.Default} => {Markup.Underline}{requestDetails.Request.Url}{Markup.ResetUnderline}"); await Pulse.RunAsync(@params, requestDetails).ConfigureAwait(false); return 0; } @@ -99,23 +99,23 @@ public static async Task CheckForUpdates(CancellationToken ct = default) { if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!DefaultJsonContext.TryDeserializeVersion(json, out var remoteVersion)) { - WriteLine(OutputPipe.Error, $"Failed to retrieve version from remote."); + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to retrieve version from remote."); return 1; } ArgumentNullException.ThrowIfNull(remoteVersion); var currentVersion = Version.Parse(VERSION); if (currentVersion < remoteVersion) { - WriteLine($"{Yellow}A new version of Pulse is available!"); - WriteLine($"Your version: {Yellow}{VERSION}"); - WriteLine($"Latest version: {Green}{remoteVersion}"); - NewLine(); - WriteLine($"Download from https://github.com/dusrdev/Pulse/releases/latest"); + Console.WriteLineInterpolated($"{Yellow}A new version of Pulse is available!"); + Console.WriteLineInterpolated($"Your version: {Markup.Underline}{Yellow}{VERSION}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"Latest version: {Markup.Underline}{Green}{remoteVersion}{Markup.ResetUnderline}"); + Console.NewLine(); + Console.WriteLineInterpolated($"Download from {Markup.Underline}https://github.com/dusrdev/Pulse/releases/latest{Markup.ResetUnderline}"); } else { - WriteLine($"{Green}You are using the latest version of Pulse."); + Console.WriteLineInterpolated($"{Green}You are using the latest version of Pulse."); } return 0; } else { - WriteLine(OutputPipe.Error, $"Failed to check for updates - server response was not success"); + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to check for updates - server response was not success"); return 1; } } @@ -125,7 +125,7 @@ public static async Task CheckForUpdates(CancellationToken ct = default) { /// /// public static int TermsOfUse() { - WriteLine( + Console.WriteLineInterpolated( $""" By using this tool you agree to take full responsibility for the consequences of its use. @@ -151,7 +151,7 @@ public static async Task GetSchema(string? directory = null, CancellationTo }; var schema = InputJsonContext.Default.RequestDetails.GetJsonSchemaAsNode(options).ToString(); await File.WriteAllTextAsync(path, schema, ct).ConfigureAwait(false); - WriteLine($"Schema generated at {Yellow}{path}"); + Console.WriteLineInterpolated($"Schema generated at {Markup.Underline}{Yellow}{path}{Markup.ResetUnderline}"); return 0; } @@ -166,7 +166,7 @@ public static async Task GetSample(string? directory = null, CancellationTo var path = Path.Join(directory, "sample.json"); var json = JsonSerializer.Serialize(new RequestDetails(), InputJsonContext.Default.RequestDetails); await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); - WriteLine($"Sample request generated at {Yellow}{path}"); + Console.WriteLineInterpolated($"Sample request generated at {Markup.Underline}{Yellow}{path}{Markup.ResetUnderline}"); return 0; } @@ -176,50 +176,50 @@ public static async Task GetSample(string? directory = null, CancellationTo /// /// internal static void PrintConfiguration(Parameters parameters, RequestDetails requestDetails) { - Color headerColor = Cyan; - Color property = DarkGray; - Color value = White; + ConsoleColor headerColor = Cyan; + ConsoleColor property = DarkGray; + ConsoleColor value = White; // Options - WriteLine($"{headerColor}Options:"); - WriteLine($"{property} Request count: {value}{parameters.Requests}"); - WriteLine($"{property} Concurrent connections: {value}{parameters.Connections}"); - WriteLine($"{property} Delay: {value}{parameters.DelayInMs}ms"); - WriteLine($"{property} Timeout: {value}{parameters.TimeoutInMs}"); - WriteLine($"{property} Export Raw: {value}{parameters.ExportRaw}"); - WriteLine($"{property} Format JSON: {value}{parameters.FormatJson}"); - WriteLine($"{property} Export Full Equality: {value}{parameters.UseFullEquality}"); - WriteLine($"{property} Export: {value}{parameters.Export}"); - WriteLine($"{property} Verbose: {value}{parameters.Verbose}"); - WriteLine($"{property} Output Folder: {value}{parameters.OutputFolder}"); + Console.WriteLineInterpolated($"{headerColor}Options:"); + Console.WriteLineInterpolated($"{property} Request count: {value}{parameters.Requests}"); + Console.WriteLineInterpolated($"{property} Concurrent connections: {value}{parameters.Connections}"); + Console.WriteLineInterpolated($"{property} Delay: {value}{parameters.DelayInMs}ms"); + Console.WriteLineInterpolated($"{property} Timeout: {value}{parameters.TimeoutInMs}"); + Console.WriteLineInterpolated($"{property} Export Raw: {value}{parameters.ExportRaw}"); + Console.WriteLineInterpolated($"{property} Format JSON: {value}{parameters.FormatJson}"); + Console.WriteLineInterpolated($"{property} Export Full Equality: {value}{parameters.UseFullEquality}"); + Console.WriteLineInterpolated($"{property} Export: {value}{parameters.Export}"); + Console.WriteLineInterpolated($"{property} Verbose: {value}{parameters.Verbose}"); + Console.WriteLineInterpolated($"{property} Output Folder: {value}{parameters.OutputFolder}"); // Request - WriteLine($"{headerColor}Request:"); - WriteLine($"{property} URL: {value}{requestDetails.Request.Url}"); - WriteLine($"{property} Method: {value}{requestDetails.Request.Method}"); - WriteLine($"{Yellow} Headers:"); + Console.WriteLineInterpolated($"{headerColor}Request:"); + Console.WriteLineInterpolated($"{property} URL: {value}{requestDetails.Request.Url}"); + Console.WriteLineInterpolated($"{property} Method: {value}{requestDetails.Request.Method}"); + Console.WriteLineInterpolated($"{Yellow} Headers:"); if (requestDetails.Request.Headers.Count > 0) { foreach (var header in requestDetails.Request.Headers) { if (header.Value is null) { continue; } - WriteLine($"{property} {header.Key}: {value}{header.Value.Value}"); + Console.WriteLineInterpolated($"{property} {header.Key}: {value}{header.Value.Value}"); } } if (requestDetails.Request.Content.Body.HasValue) { - WriteLine($"{Yellow} Content:"); - WriteLine($"{property} ContentType: {value}{requestDetails.Request.Content.GetContentType()}"); - WriteLine($"{property} Body: {value}{requestDetails.Request.Content.Body}"); + Console.WriteLineInterpolated($"{Yellow} Content:"); + Console.WriteLineInterpolated($"{property} ContentType: {value}{requestDetails.Request.Content.GetContentType()}"); + Console.WriteLineInterpolated($"{property} Body: {value}{requestDetails.Request.Content.Body}"); } else { - WriteLine($"{property} Content: {value}none"); + Console.WriteLineInterpolated($"{property} Content: {value}none"); } // Proxy - WriteLine($"{headerColor}Proxy:"); - WriteLine($"{property} Bypass: {value}{requestDetails.Proxy.Bypass}"); - WriteLine($"{property} Host: {value}{requestDetails.Proxy.Host}"); - WriteLine($"{property} Username: {value}{requestDetails.Proxy.Username}"); - WriteLine($"{property} Password: {value}{requestDetails.Proxy.Password}"); - WriteLine($"{property} Ignore SSL: {value}{requestDetails.Proxy.IgnoreSSL}"); + Console.WriteLineInterpolated($"{headerColor}Proxy:"); + Console.WriteLineInterpolated($"{property} Bypass: {value}{requestDetails.Proxy.Bypass}"); + Console.WriteLineInterpolated($"{property} Host: {value}{requestDetails.Proxy.Host}"); + Console.WriteLineInterpolated($"{property} Username: {value}{requestDetails.Proxy.Username}"); + Console.WriteLineInterpolated($"{property} Password: {value}{requestDetails.Proxy.Password}"); + Console.WriteLineInterpolated($"{property} Ignore SSL: {value}{requestDetails.Proxy.IgnoreSSL}"); } } diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index 27a180b..c92a9cb 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -15,7 +15,7 @@ public static void ReportLinesFromCurrent(int lineCount) { return; } - int current = GetCurrentLine(); + int current = Console.GetCurrentLine(); int lastLine = current + lineCount - 1; UpdateMax(lastLine); } diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index ebb8851..b8e40f6 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -8,31 +8,31 @@ namespace Pulse.Core; internal sealed class GlobalExceptionHandler(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { - int startLine = GetCurrentLine(); + int startLine = Console.GetCurrentLine(); ConsoleState.Reset(startLine); try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { ClearFrom(startLine); - WriteLine(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); Environment.ExitCode = 1; } catch (Exception e) { ClearFrom(startLine); - WriteLine(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: https://dusrdev.github.io"); - WriteLine(OutputPipe.Error, $"{Red}and provide the following details:"); - NewLine(OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}and provide the following details:"); + Console.NewLine(OutputPipe.Error); Helper.PrintException(StrippedException.FromException(e)); Environment.ExitCode = 1; } static void ClearFrom(int start) { - int last = Math.Max(GetCurrentLine(), ConsoleState.LinesWritten); + int last = Math.Max(Console.GetCurrentLine(), ConsoleState.LinesWritten); int lines = Math.Max(1, last - start + 1); - GoToLine(start); - ClearNextLines(lines, OutputPipe.Error); - GoToLine(start); - ClearNextLines(lines, OutputPipe.Out); - GoToLine(start); + Console.GoToLine(start); + Console.ClearNextLines(lines, OutputPipe.Error); + Console.GoToLine(start); + Console.ClearNextLines(lines, OutputPipe.Out); + Console.GoToLine(start); } } } diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index ec10894..639abee 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -29,7 +29,7 @@ public static TimeSpan GetETA(double percentage, TimeSpan elapsed) { /// /// /// - public static Color GetPercentageBasedColor(double percentage) { + public static ConsoleColor GetPercentageBasedColor(double percentage) { ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)percentage, 100); return percentage switch { @@ -44,7 +44,7 @@ public static Color GetPercentageBasedColor(double percentage) { /// /// /// - public static Color GetStatusCodeBasedColor(int statusCode) { + public static ConsoleColor GetStatusCodeBasedColor(int statusCode) { return statusCode switch { < 100 => Magenta, < 200 => White, @@ -61,7 +61,7 @@ public static Color GetStatusCodeBasedColor(int statusCode) { /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static Color GetMethodBasedColor(string method) + public static ConsoleColor GetMethodBasedColor(string method) => method switch { "GET" => Green, "DELETE" => Red, @@ -89,19 +89,19 @@ public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy p /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void PrintException(this StrippedException e) { - WriteLine(OutputPipe.Error, $"{Yellow}Exception type: {Default}{e.Type}"); - WriteLine(OutputPipe.Error, $"{Yellow}Message: {Default}{e.Message}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Exception type: {ConsoleColor.Default}{e.Type}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Message: {ConsoleColor.Default}{e.Message}"); if (e.Detail is not null) { - WriteLine(OutputPipe.Error, $"{Yellow}Detail: {Default}{e.Detail}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Detail: {ConsoleColor.Default}{e.Detail}"); } if (e.InnerException is null or { IsDefault: true }) { return; } - NewLine(OutputPipe.Error); - WriteLine($"{Magenta}Inner exception:"); + Console.NewLine(OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}Inner exception:"); PrintException(e.InnerException); } diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 1126327..bd2b710 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -119,11 +119,11 @@ private async ValueTask PushMetricsAsync() { [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void PrintMetrics(Stats stats) { - Overwrite(stats, static s => { - Write(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{Default}/{Yellow}{s.RequestCount}{Default} "); + Console.Overwrite(stats, static s => { + Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green); - WriteLine(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{Default}%, Estimated time remaining: {Yellow}{s.ETA:hr}"); - WriteLine(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{Default}, 2xx: {Green}{s.StatusCodes[2].Value}{Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{Default}, 4xx: {Red}{s.StatusCodes[4].Value}{Default}, 5xx: {Red}{s.StatusCodes[5].Value}{Default}, others: {Magenta}{s.StatusCodes[0].Value}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.ETA:hr}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); }, 3, OutputPipe.Error); } @@ -141,8 +141,8 @@ public async Task ClearAndReturnAsync() { // Clear after metrics _channel.Writer.Complete(); await _printer.ConfigureAwait(false); - ClearNextLines(3, OutputPipe.Error); - System.Console.CursorVisible = true; + Console.ClearNextLines(3, OutputPipe.Error); + Console.CursorVisible = true; return new() { Results = _results, diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 89d1da5..c6a2bb8 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -34,8 +34,8 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( int peakConcurrentConnections = 0; ConsoleState.ReportLinesFromCurrent(1); - Overwrite(() => { - Write(OutputPipe.Error, $"Cross referencing results..."); + Console.Overwrite(() => { + Console.WriteInterpolated(OutputPipe.Error, $"Cross referencing results..."); }, 1, OutputPipe.Error); foreach (var result in pulseResult.Results) { @@ -67,28 +67,28 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; // Clear "cross referencing results..." - ClearNextLines(1, OutputPipe.Error); + Console.ClearNextLines(1, OutputPipe.Error); - WriteLine($"Request count: {Yellow}{completed}"); - WriteLine($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); - WriteLine($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); - WriteLine($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); - WriteLine($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{Default}, Max: {Red}{latencySummary.Max:0.##}ms"); + Console.WriteLineInterpolated($"Request count: {Yellow}{completed}"); + Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); + Console.WriteLineInterpolated($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{latencySummary.Max:0.##}ms"); if (latencySummary.Removed != 0) { - WriteLine($" (Removed {DarkYellow}{latencySummary.Removed}{Default} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); + Console.WriteLineInterpolated($" (Removed {DarkYellow}{latencySummary.Removed}{ConsoleColor.Default} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); } - WriteLine($"Content Size: Min: {Green}{getSize(sizeSummary.Min)}{Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{Default}, Max: {Red}{getSize(sizeSummary.Max)}"); - WriteLine($"Total throughput: {Yellow}{getSize(throughput)}/s"); - WriteLine($"Status codes:"); + Console.WriteLineInterpolated($"Content Size: Min: {Green}{getSize(sizeSummary.Min)}{ConsoleColor.Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{ConsoleColor.Default}, Max: {Red}{getSize(sizeSummary.Max)}"); + Console.WriteLineInterpolated($"Total throughput: {Yellow}{getSize(throughput)}/s"); + Console.WriteLineInterpolated($"Status codes:"); foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { var key = (int)kvp.Key; if (key is 0) { - WriteLine($" {Magenta}{key}{Default} --> {kvp.Value} [StatusCode 0 = Exception]"); + Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); } else { - WriteLine($" {Helper.GetStatusCodeBasedColor(key)}{key}{Default} --> {kvp.Value}"); + Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); } } - NewLine(); + Console.NewLine(); return (parameters.Export, uniqueRequests); } @@ -103,21 +103,21 @@ public static (bool exportRequired, HashSet uniqueRequests) SummarizeS double duration = result.Latency.TotalMilliseconds; var statusCode = result.StatusCode; - WriteLine($"Request count: {Yellow}1"); - WriteLine($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + Console.WriteLineInterpolated($"Request count: {Yellow}1"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); if ((int)statusCode is >= 200 and < 300) { - WriteLine($"Success: {Green}true"); + Console.WriteLineInterpolated($"Success: {Green}true"); } else { - WriteLine($"Success: {Red}false"); + Console.WriteLineInterpolated($"Success: {Red}false"); } - WriteLine($"Latency: {Green}{duration:0.##}ms"); - WriteLine($"Content Size: {Green}{Utils.Strings.FormatBytes(result.ContentLength)}"); + Console.WriteLineInterpolated($"Latency: {Green}{duration:0.##}ms"); + Console.WriteLineInterpolated($"Content Size: {Green}{Utils.Strings.FormatBytes(result.ContentLength)}"); if (statusCode is 0) { - WriteLine($"Status code: {Red}0 [Exception]"); + Console.WriteLineInterpolated($"Status code: {Red}0 [Exception]"); } else { - WriteLine($"Status code: {Helper.GetStatusCodeBasedColor((int)statusCode)}{statusCode}"); + Console.WriteLineInterpolated($"Status code: {Helper.GetStatusCodeBasedColor((int)statusCode)}{statusCode}"); } - NewLine(); + Console.NewLine(); var uniqueRequests = new HashSet(1) { result }; @@ -243,7 +243,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe var count = uniqueRequests.Count; if (count is 0) { - WriteLine($"{Yellow}No unique results found to export..."); + Console.WriteLineInterpolated($"{Yellow}No unique results found to export..."); return; } @@ -253,7 +253,7 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe if (count is 1) { await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token).ConfigureAwait(false); - WriteLine($"{Green}1{Default} unique response exported to {Yellow}{directory}"); + Console.WriteLineInterpolated($"{Green}1{ConsoleColor.Default} unique response exported to {Yellow}{directory}"); return; } @@ -264,6 +264,6 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn).ConfigureAwait(false)).ConfigureAwait(false); - WriteLine($"{Green}{count}{Default} unique responses exported to {Yellow}{directory}{Default}"); + Console.WriteLineInterpolated($"{Green}{count}{ConsoleColor.Default} unique responses exported to {Yellow}{directory}{ConsoleColor.Default}"); } } diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 91695fe..a540018 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -56,7 +56,7 @@ public VerbosePulseMonitor(HttpClient client, Request requestRecipe, Parameters /// public async Task SendAsync(int requestId) { lock (_lock) { - WriteLine(OutputPipe.Error, $"{Yellow}--> {Default}Sent request: {Yellow}{requestId}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}--> {ConsoleColor.Default}Sent request: {Yellow}{requestId}"); } var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); Interlocked.Increment(ref _responses.Value); @@ -66,14 +66,14 @@ public async Task SendAsync(int requestId) { } int status = (int)result.StatusCode; lock (_lock) { - WriteLine(OutputPipe.Error, $"{Yellow}<-- {Default}Received response: {Yellow}{requestId}{Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}<-- {ConsoleColor.Default}Received response: {Yellow}{requestId}{ConsoleColor.Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); } _results.Push(result); } /// public Task ClearAndReturnAsync() { - NewLine(OutputPipe.Error); + Console.NewLine(OutputPipe.Error); return Task.FromResult(new PulseResult { Results = _results, diff --git a/src/Pulse/GlobalUsings.cs b/src/Pulse/GlobalUsings.cs index 858a439..832bbd2 100644 --- a/src/Pulse/GlobalUsings.cs +++ b/src/Pulse/GlobalUsings.cs @@ -2,5 +2,4 @@ global using PrettyConsole; -global using static PrettyConsole.Color; -global using static PrettyConsole.Console; +global using static System.ConsoleColor; diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 90905fb..de6ec93 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -28,12 +28,12 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From c7e2d6d2026395af09740de1a3c245e47d2bc58f Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 15 Nov 2025 16:17:08 +0200 Subject: [PATCH 035/105] Add file-based app for profiling --- profiling/runner.cs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 profiling/runner.cs diff --git a/profiling/runner.cs b/profiling/runner.cs new file mode 100644 index 0000000..b16a21c --- /dev/null +++ b/profiling/runner.cs @@ -0,0 +1,40 @@ +#:package PrettyConsole@5.0.0 +#:package CliWrap@3.9.0 + +using CliWrap; +using CliWrap.Buffered; + +using PrettyConsole; + +string directory = ""; +string executable = ""; + +string[][] commands = [ + ["--help"], // show help + ["get-sample"], // get-sample json file + ["sample.json", "-u", "http://127.0.0.1:3000/"], // single request - default + ["sample.json", "-u", "http://127.0.0.1:3000/json/", "-n", "100", "--json"], // 100 concurrent + format + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-c", "10", "--raw"], // no format + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "10", "-f", "--raw"], // full equality + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-v", "--raw"], // verbose mode +]; + +foreach (var command in commands) { + var result = await Cli.Wrap(executable) + .WithArguments(command) + .WithWorkingDirectory(directory) + .ExecuteBufferedAsync(); + if (result.ExitCode != 0) { + if (result.StandardOutput.Length > 0) { + Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard output:"); + Console.Write(result.StandardOutput.AsSpan(), OutputPipe.Out); + } + if (result.StandardError.Length > 0) { + Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard error:"); + Console.Write(result.StandardError.AsSpan(), OutputPipe.Out); + } + return 1; + } +} + +return 0; \ No newline at end of file From 25fd49b9caf697a3df7a32d17d866c7caf887d35 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 08:29:27 +0200 Subject: [PATCH 036/105] Use file-based app for runner --- profiling/pulse_profiler/Cargo.lock | 7 --- profiling/pulse_profiler/Cargo.toml | 18 ------- profiling/pulse_profiler/src/main.rs | 51 ------------------- profiling/runner.cs | 73 +++++++++++++++++----------- 4 files changed, 45 insertions(+), 104 deletions(-) delete mode 100644 profiling/pulse_profiler/Cargo.lock delete mode 100644 profiling/pulse_profiler/Cargo.toml delete mode 100644 profiling/pulse_profiler/src/main.rs diff --git a/profiling/pulse_profiler/Cargo.lock b/profiling/pulse_profiler/Cargo.lock deleted file mode 100644 index 6396fea..0000000 --- a/profiling/pulse_profiler/Cargo.lock +++ /dev/null @@ -1,7 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "pulse_profiler" -version = "0.1.0" diff --git a/profiling/pulse_profiler/Cargo.toml b/profiling/pulse_profiler/Cargo.toml deleted file mode 100644 index 21baef4..0000000 --- a/profiling/pulse_profiler/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "pulse_profiler" -version = "0.1.0" -edition = "2024" - -[dependencies] - -[profile.release] -opt-level = 3 -lto = "fat" -codegen-units = 1 -panic = "abort" -strip = "symbols" - -[profile.dev] -opt-level = 2 -debug = 1 -incremental = true \ No newline at end of file diff --git a/profiling/pulse_profiler/src/main.rs b/profiling/pulse_profiler/src/main.rs deleted file mode 100644 index b4ff1ad..0000000 --- a/profiling/pulse_profiler/src/main.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::{path::Path, process::{Command, Stdio, ExitCode}}; - -fn main() -> ExitCode { - let commands: Vec<&str> = vec![ - "--help", - "get-sample", - "sample.json -u \"http://127.0.0.1:3000/\"", // one string - "sample.json -n 100 -u \"http://127.0.0.1:3000/json\" --json", // 100 json with formatting - "sample.json -n 100 -c 10 -u \"http://127.0.0.1:3000/html\" --raw", // 100 html limited raw - "sample.json -n 10 -u \"http://127.0.0.1:3000/html\" --raw -f", // 10 html full equality - "sample.json -n 100 -v -u \"http://127.0.0.1:3000/html\" --raw", // 100 html parallel verbose (race) raw - ]; - - for command in &commands { - let code = run_pulse(&command); - - if code != 0 { - return ExitCode::from(1); - } - } - - return ExitCode::from(0); -} - -fn run_pulse(args: &str) -> i32 { - let dir = &Path::new("../../../src/Pulse/publish/"); - let path = &Path::new("../../../src/Pulse/publish/Pulse"); - - let mut binding = Command::new(path); - - let command = binding - .current_dir(&dir) - .arg(&args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - let output = command.output().unwrap(); - - if !output.status.success() { - // preserve stderr in the error - let code = output.status.code().unwrap(); - let err = String::from_utf8_lossy(&output.stderr); - println!("Command failed with code: {code}"); - println!("Message:"); - println!("{err}"); - return code; - } - - return 0; -} \ No newline at end of file diff --git a/profiling/runner.cs b/profiling/runner.cs index b16a21c..e568404 100644 --- a/profiling/runner.cs +++ b/profiling/runner.cs @@ -1,40 +1,57 @@ #:package PrettyConsole@5.0.0 +#:package ConsoleAppFramework@5.7.9 #:package CliWrap@3.9.0 using CliWrap; using CliWrap.Buffered; +using ConsoleAppFramework; + using PrettyConsole; -string directory = ""; -string executable = ""; - -string[][] commands = [ - ["--help"], // show help - ["get-sample"], // get-sample json file - ["sample.json", "-u", "http://127.0.0.1:3000/"], // single request - default - ["sample.json", "-u", "http://127.0.0.1:3000/json/", "-n", "100", "--json"], // 100 concurrent + format - ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-c", "10", "--raw"], // no format - ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "10", "-f", "--raw"], // full equality - ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-v", "--raw"], // verbose mode -]; - -foreach (var command in commands) { - var result = await Cli.Wrap(executable) - .WithArguments(command) - .WithWorkingDirectory(directory) - .ExecuteBufferedAsync(); - if (result.ExitCode != 0) { - if (result.StandardOutput.Length > 0) { - Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard output:"); - Console.Write(result.StandardOutput.AsSpan(), OutputPipe.Out); +await ConsoleApp.RunAsync(args, Commands.Root); + +static class Commands { + public static async Task Root(string directoryPath, CancellationToken ct) { + string[][] commands = [ + ["--help"], // show help + ["get-sample"], // get-sample json file + ["sample.json", "-u", "http://127.0.0.1:3000/"], // single request - default + ["sample.json", "-u", "http://127.0.0.1:3000/json/", "-n", "100", "--json"], // 100 concurrent + format + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-c", "10", "--raw"], // no format + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "10", "-f", "--raw"], // full equality + ["sample.json", "-u", "http://127.0.0.1:3000/html/", "-n", "100", "-v", "--raw"], // verbose mode + ]; + + string appName = OperatingSystem.IsWindows() + ? "Pulse.exe" + : "Pulse"; + + string executable = Path.Join(directoryPath, appName); + + if (!File.Exists(executable)) { + Console.WriteLineInterpolated($"{ConsoleColor.Red}Could not find the executable at {Markup.Underline}{executable}{Markup.ResetUnderline}."); + return 1; } - if (result.StandardError.Length > 0) { - Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard error:"); - Console.Write(result.StandardError.AsSpan(), OutputPipe.Out); + + foreach (var command in commands) { + var result = await Cli.Wrap(executable) + .WithArguments(command) + .WithWorkingDirectory(directoryPath) + .ExecuteBufferedAsync(cancellationToken: ct); + if (result.ExitCode != 0) { + if (result.StandardOutput.Length > 0) { + Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard output:"); + Console.Write(result.StandardOutput.AsSpan(), OutputPipe.Out); + } + if (result.StandardError.Length > 0) { + Console.WriteLineInterpolated($"{ConsoleColor.Green}Standard error:"); + Console.Write(result.StandardError.AsSpan(), OutputPipe.Out); + } + return 1; + } } - return 1; + + return 0; } } - -return 0; \ No newline at end of file From f45b38fd3ae1de1253398b175ff23a13677eba47 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 08:59:58 +0200 Subject: [PATCH 037/105] use file-based app for server --- profiling/pulse_profiler_server/Cargo.lock | 583 ------------------ profiling/pulse_profiler_server/Cargo.toml | 20 - .../pulse_profiler_server/data/payload.json | 59 -- profiling/pulse_profiler_server/src/main.rs | 65 -- .../data/payload.html => server.cs} | 92 ++- 5 files changed, 91 insertions(+), 728 deletions(-) delete mode 100644 profiling/pulse_profiler_server/Cargo.lock delete mode 100644 profiling/pulse_profiler_server/Cargo.toml delete mode 100644 profiling/pulse_profiler_server/data/payload.json delete mode 100644 profiling/pulse_profiler_server/src/main.rs rename profiling/{pulse_profiler_server/data/payload.html => server.cs} (58%) diff --git a/profiling/pulse_profiler_server/Cargo.lock b/profiling/pulse_profiler_server/Cargo.lock deleted file mode 100644 index 211c599..0000000 --- a/profiling/pulse_profiler_server/Cargo.lock +++ /dev/null @@ -1,583 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "axum" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "pin-utils", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-util" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "libc" -version = "0.2.177" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" - -[[package]] -name = "log" -version = "0.4.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pulse_profiler_server" -version = "0.1.0" -dependencies = [ - "axum", - "tokio", -] - -[[package]] -name = "quote" -version = "1.0.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" -dependencies = [ - "libc", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "syn" -version = "2.0.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" diff --git a/profiling/pulse_profiler_server/Cargo.toml b/profiling/pulse_profiler_server/Cargo.toml deleted file mode 100644 index 2ade359..0000000 --- a/profiling/pulse_profiler_server/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "pulse_profiler_server" -version = "0.1.0" -edition = "2024" - -[dependencies] -axum = "0.8.6" -tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "signal"] } - -[profile.release] -opt-level = 3 -lto = "fat" -codegen-units = 1 -panic = "abort" -strip = "symbols" - -[profile.dev] -opt-level = 2 -debug = 1 -incremental = true diff --git a/profiling/pulse_profiler_server/data/payload.json b/profiling/pulse_profiler_server/data/payload.json deleted file mode 100644 index d871d41..0000000 --- a/profiling/pulse_profiler_server/data/payload.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "data": [ - { "id": 1, "name": "Item 1", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 2, "name": "Item 2", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 3, "name": "Item 3", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 4, "name": "Item 4", "value": 100.2, "active": true, "tags": [] }, - { "id": 5, "name": "Item 5", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 6, "name": "Item 6", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 7, "name": "Item 7", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 8, "name": "Item 8", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 9, "name": "Item 9", "value": 100.2, "active": true, "tags": [] }, - { "id": 10, "name": "Item 10", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 11, "name": "Item 11", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 12, "name": "Item 12", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 13, "name": "Item 13", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 14, "name": "Item 14", "value": 100.2, "active": true, "tags": [] }, - { "id": 15, "name": "Item 15", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 16, "name": "Item 16", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 17, "name": "Item 17", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 18, "name": "Item 18", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 19, "name": "V20", "value": 100.2, "active": true, "tags": [] }, - { "id": 20, "name": "Item 20", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 21, "name": "Item 21", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 22, "name": "Item 22", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 23, "name": "Item 23", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 24, "name": "Item 24", "value": 100.2, "active": true, "tags": [] }, - { "id": 25, "name": "Item 25", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 26, "name": "Item 26", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 27, "name": "Item 27", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 28, "name": "Item 28", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 29, "name": "Item 29", "value": 100.2, "active": true, "tags": [] }, - { "id": 30, "name": "Item 30", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 31, "name": "Item 31", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 32, "name": "Item 32", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 33, "name": "Item 33", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 34, "name": "Item 34", "value": 100.2, "active": true, "tags": [] }, - { "id": 35, "name": "Item 35", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 36, "name": "Item 36", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 37, "name": "Item 37", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 38, "name": "Item 38", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 39, "name": "Item 39", "value": 100.2, "active": true, "tags": [] }, - { "id": 40, "name": "Item 40", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 41, "name": "Item 41", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 42, "name": "Item 42", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 43, "name": "Item 43", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 44, "name": "Item 44", "value": 100.2, "active": true, "tags": [] }, - { "id": 45, "name": "Item 45", "value": 0.5, "active": false, "tags": ["f"] }, - { "id": 46, "name": "Item 46", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, - { "id": 47, "name": "Item 47", "value": 20.0, "active": false, "tags": ["b", "d"] }, - { "id": 48, "name": "Item 48", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, - { "id": 49, "name": "Item 49", "value": 100.2, "active": true, "tags": [] }, - { "id": 50, "name": "Item 50", "value": 0.5, "active": false, "tags": ["f"] } - ], - "metadata": { - "count": 50, - "timestamp": "2025-11-04T10:42:00Z", - "source": "pgo-test-server" - } -} \ No newline at end of file diff --git a/profiling/pulse_profiler_server/src/main.rs b/profiling/pulse_profiler_server/src/main.rs deleted file mode 100644 index 958f670..0000000 --- a/profiling/pulse_profiler_server/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -use axum::{ - Router, - http::HeaderName, - response::Html, // Use this for a proper text/html content type - routing::get, -}; -use tokio::signal; -use std::net::SocketAddr; - -const JSON_PAYLOAD: &str = include_str!("../data/payload.json"); - -const HTML_PAYLOAD: &str = include_str!("../data/payload.html"); - -#[tokio::main] -async fn main() { - let app = Router::new() - .route("/", get(root_handler)) - .route("/html", get(html_handler)) - .route("/json", get(json_handler)); - - let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); - println!("Test server with HTML/JSON endpoints listening on {addr}"); - - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await - .unwrap(); -} - -async fn root_handler() -> &'static str { - return "Hello!"; -} - -async fn html_handler() -> Html<&'static str> { - return Html(HTML_PAYLOAD); -} - -async fn json_handler() -> ([(HeaderName, &'static str); 1], &'static str) { - let headers = [(HeaderName::from_static("content-type"), "application/json")]; - return (headers, JSON_PAYLOAD); -} - -// A future that completes when a shutdown signal is received. -async fn shutdown_signal() { - // Wait for the (Ctrl+C) signal - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - // Wait for a termination signal (on Unix) - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - // On Windows, we only need to listen for Ctrl+C - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - // Wait for either signal - tokio::select! { _ = ctrl_c => { println!("\nCtrl+C received, shutting down...") }, _ = terminate => { println!("\nTerminate signal received, shutting down...") }, } -} diff --git a/profiling/pulse_profiler_server/data/payload.html b/profiling/server.cs similarity index 58% rename from profiling/pulse_profiler_server/data/payload.html rename to profiling/server.cs index 5dcd859..e73a3b1 100644 --- a/profiling/pulse_profiler_server/data/payload.html +++ b/profiling/server.cs @@ -1,3 +1,88 @@ +#:sdk Microsoft.Net.Sdk.Web@* +#:property CompileAot=false +#:property PublishTrimmed=false + +var builder = WebApplication.CreateBuilder(); + +const string address = "http://0.0.0.0:3000"; + +builder.WebHost.UseUrls(address); + +var app = builder.Build(); + +Console.WriteLine($"Server is running at {address}."); + +app.MapGet("/", static () => "Hello!"); + +app.MapGet("/json", static () => { + const string payload = +""" +{ + "data": [ + { "id": 1, "name": "Item 1", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 2, "name": "Item 2", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 3, "name": "Item 3", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 4, "name": "Item 4", "value": 100.2, "active": true, "tags": [] }, + { "id": 5, "name": "Item 5", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 6, "name": "Item 6", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 7, "name": "Item 7", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 8, "name": "Item 8", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 9, "name": "Item 9", "value": 100.2, "active": true, "tags": [] }, + { "id": 10, "name": "Item 10", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 11, "name": "Item 11", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 12, "name": "Item 12", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 13, "name": "Item 13", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 14, "name": "Item 14", "value": 100.2, "active": true, "tags": [] }, + { "id": 15, "name": "Item 15", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 16, "name": "Item 16", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 17, "name": "Item 17", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 18, "name": "Item 18", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 19, "name": "V20", "value": 100.2, "active": true, "tags": [] }, + { "id": 20, "name": "Item 20", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 21, "name": "Item 21", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 22, "name": "Item 22", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 23, "name": "Item 23", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 24, "name": "Item 24", "value": 100.2, "active": true, "tags": [] }, + { "id": 25, "name": "Item 25", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 26, "name": "Item 26", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 27, "name": "Item 27", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 28, "name": "Item 28", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 29, "name": "Item 29", "value": 100.2, "active": true, "tags": [] }, + { "id": 30, "name": "Item 30", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 31, "name": "Item 31", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 32, "name": "Item 32", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 33, "name": "Item 33", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 34, "name": "Item 34", "value": 100.2, "active": true, "tags": [] }, + { "id": 35, "name": "Item 35", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 36, "name": "Item 36", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 37, "name": "Item 37", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 38, "name": "Item 38", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 39, "name": "Item 39", "value": 100.2, "active": true, "tags": [] }, + { "id": 40, "name": "Item 40", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 41, "name": "Item 41", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 42, "name": "Item 42", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 43, "name": "Item 43", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 44, "name": "Item 44", "value": 100.2, "active": true, "tags": [] }, + { "id": 45, "name": "Item 45", "value": 0.5, "active": false, "tags": ["f"] }, + { "id": 46, "name": "Item 46", "value": 10.5, "active": true, "tags": ["a", "b", "c"] }, + { "id": 47, "name": "Item 47", "value": 20.0, "active": false, "tags": ["b", "d"] }, + { "id": 48, "name": "Item 48", "value": 5.75, "active": true, "tags": ["a", "c", "e"] }, + { "id": 49, "name": "Item 49", "value": 100.2, "active": true, "tags": [] }, + { "id": 50, "name": "Item 50", "value": 0.5, "active": false, "tags": ["f"] } + ], + "metadata": { + "count": 50, + "timestamp": "2025-11-04T10:42:00Z", + "source": "pgo-test-server" + } +} +"""; + return payload; +}); + +app.MapGet("/html", static () => { + const string payload = +""" @@ -206,4 +291,9 @@

Section 9

consequat,
- \ No newline at end of file + +"""; + return payload; +}); + +app.Run(); \ No newline at end of file From 4e43bb1c2ef4f4e2255b9a99fbeaff40accb6b61 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 09:14:40 +0200 Subject: [PATCH 038/105] CommandAppFramework should handle validation and parsing exceptions --- src/Pulse/Core/GlobalExceptionHandler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index b8e40f6..268967f 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + using ConsoleAppFramework; using Pulse.Configuration; @@ -12,7 +14,9 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo ConsoleState.Reset(startLine); try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); - } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { + } catch (Exception e) when (e is ValidationException or ArgumentParseFailedException) { + throw; + } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { ClearFrom(startLine); Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); Environment.ExitCode = 1; From 00250ee1bf20423fffbe7190d71a819a33152282 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 09:14:54 +0200 Subject: [PATCH 039/105] Add json schema command --- src/Pulse/Program.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index d769306..0f856f1 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,4 +1,6 @@ -using ConsoleAppFramework; +using System.Text.Json; + +using ConsoleAppFramework; using Pulse.Core; @@ -14,4 +16,11 @@ app.Add("check-for-updates", Commands.CheckForUpdates); app.Add("terms-of-use", Commands.TermsOfUse); -await app.RunAsync(args).ConfigureAwait(false); +app.Add("cli-schema", () => { + CommandHelpDefinition[] schema = app.GetCliSchema(); + ReadOnlySpan json = JsonSerializer.Serialize(schema, CliSchemaJsonSerializerContext.Default.CommandHelpDefinitionArray); + Console.WriteLine(json); + return 0; +}); + +await app.RunAsync(args).ConfigureAwait(false); \ No newline at end of file From b8b493beaf941f3b9d3a1b147f7450158d066479 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 09:15:05 +0200 Subject: [PATCH 040/105] updated dependencies --- src/Pulse/Pulse.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index de6ec93..581fe55 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -28,10 +28,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all + From 0adc63e8c0e049bfe66a48247cac7b634d1397f5 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 12:29:05 +0200 Subject: [PATCH 041/105] remove redundant address output --- profiling/server.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/profiling/server.cs b/profiling/server.cs index e73a3b1..1eab876 100644 --- a/profiling/server.cs +++ b/profiling/server.cs @@ -10,8 +10,6 @@ var app = builder.Build(); -Console.WriteLine($"Server is running at {address}."); - app.MapGet("/", static () => "Hello!"); app.MapGet("/json", static () => { From 0ebb67a7e670124e91fc6999a500ca9b821cdf96 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 12:37:08 +0200 Subject: [PATCH 042/105] server improvement --- profiling/server.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/profiling/server.cs b/profiling/server.cs index 1eab876..92c6777 100644 --- a/profiling/server.cs +++ b/profiling/server.cs @@ -10,7 +10,7 @@ var app = builder.Build(); -app.MapGet("/", static () => "Hello!"); +app.MapGet("/", static () => Results.Ok("Hello!")); app.MapGet("/json", static () => { const string payload = @@ -75,7 +75,7 @@ } } """; - return payload; + return Results.Ok(payload); }); app.MapGet("/html", static () => { @@ -291,7 +291,7 @@ """; - return payload; + return Results.Ok(payload); }); app.Run(); \ No newline at end of file From f1a876c524ac4b140edb0a7eec6e9bf38a09797e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:00:15 +0200 Subject: [PATCH 043/105] Remove dependency on sharpify + fix invalid formats --- src/Pulse/Core/PulseHttpClientFactory.cs | 6 ++---- src/Pulse/Core/PulseMonitor.cs | 5 +++-- src/Pulse/Core/PulseSummary.cs | 15 ++++++--------- src/Pulse/Pulse.csproj | 1 - 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index a3cc853..37a1c7e 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -1,7 +1,5 @@ using System.Net; -using Sharpify; - namespace Pulse.Core; /// @@ -35,11 +33,11 @@ public static HttpClient Create(Proxy proxyDetails, int timeoutInMs) { /// internal static SocketsHttpHandler CreateHandler(Proxy proxyDetails) { SocketsHttpHandler handler; - if (proxyDetails.Bypass || proxyDetails.Host.IsNullOrWhiteSpace()) { + if (proxyDetails.Bypass || proxyDetails.Host is null or { Length: 0 }) { handler = new SocketsHttpHandler(); } else { var proxy = new WebProxy(proxyDetails.Host); - if (!proxyDetails.Username.IsNullOrWhiteSpace() && !proxyDetails.Password.IsNullOrWhiteSpace()) { + if (proxyDetails.Username.Length > 0 && proxyDetails.Password.Length > 0) { proxy.Credentials = new NetworkCredential { UserName = proxyDetails.Username, Password = proxyDetails.Password diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index bd2b710..ff5d513 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -121,8 +121,9 @@ private async ValueTask PushMetricsAsync() { private static void PrintMetrics(Stats stats) { Console.Overwrite(stats, static s => { Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); - ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green); - Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.ETA:hr}"); + ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green, maxLineWidth: 34); + Console.NewLine(OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.ETA:duration}"); Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); }, 3, OutputPipe.Error); } diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index c6a2bb8..9d8082b 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -5,8 +5,6 @@ using Pulse.Configuration; -using Sharpify; - namespace Pulse.Core; /// @@ -41,7 +39,7 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( foreach (var result in pulseResult.Results) { uniqueRequests.Add(result); var statusCode = result.StatusCode; - statusCounter.GetValueRefOrAddDefault(statusCode, out _)++; + CollectionsMarshal.GetValueRefOrAddDefault(statusCounter, statusCode, out _)++; totalSize += requestSizeInBytes; peakConcurrentConnections = Math.Max(peakConcurrentConnections, result.CurrentConcurrentConnections); @@ -63,7 +61,6 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( } Summary latencySummary = GetSummary(CollectionsMarshal.AsSpan(latencies)); Summary sizeSummary = GetSummary(CollectionsMarshal.AsSpan(sizes), false); - Func getSize = Utils.Strings.FormatBytes; double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; // Clear "cross referencing results..." @@ -71,14 +68,14 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( Console.WriteLineInterpolated($"Request count: {Yellow}{completed}"); Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); - Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:duration}"); Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); Console.WriteLineInterpolated($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{latencySummary.Max:0.##}ms"); if (latencySummary.Removed != 0) { Console.WriteLineInterpolated($" (Removed {DarkYellow}{latencySummary.Removed}{ConsoleColor.Default} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); } - Console.WriteLineInterpolated($"Content Size: Min: {Green}{getSize(sizeSummary.Min)}{ConsoleColor.Default}, Mean: {Yellow}{getSize(sizeSummary.Mean)}{ConsoleColor.Default}, Max: {Red}{getSize(sizeSummary.Max)}"); - Console.WriteLineInterpolated($"Total throughput: {Yellow}{getSize(throughput)}/s"); + Console.WriteLineInterpolated($"Content Size: Min: {Green}{sizeSummary.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{sizeSummary.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{sizeSummary.Max:bytes}"); + Console.WriteLineInterpolated($"Total throughput: {Yellow}{throughput:bytes}/s"); Console.WriteLineInterpolated($"Status codes:"); foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { var key = (int)kvp.Key; @@ -104,14 +101,14 @@ public static (bool exportRequired, HashSet uniqueRequests) SummarizeS var statusCode = result.StatusCode; Console.WriteLineInterpolated($"Request count: {Yellow}1"); - Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:hr}"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:duration}"); if ((int)statusCode is >= 200 and < 300) { Console.WriteLineInterpolated($"Success: {Green}true"); } else { Console.WriteLineInterpolated($"Success: {Red}false"); } Console.WriteLineInterpolated($"Latency: {Green}{duration:0.##}ms"); - Console.WriteLineInterpolated($"Content Size: {Green}{Utils.Strings.FormatBytes(result.ContentLength)}"); + Console.WriteLineInterpolated($"Content Size: {Green}{(double)result.ContentLength:bytes}"); if (statusCode is 0) { Console.WriteLineInterpolated($"Status code: {Red}0 [Exception]"); } else { diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 581fe55..4d758b3 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -33,7 +33,6 @@ all - From 7aa19456fcdd11dfd227ac1cc721524efe66cbb2 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:03:01 +0200 Subject: [PATCH 044/105] Update .net version --- .github/workflows/publish-current-release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-current-release.yaml b/.github/workflows/publish-current-release.yaml index 143fa32..abfe147 100644 --- a/.github/workflows/publish-current-release.yaml +++ b/.github/workflows/publish-current-release.yaml @@ -48,7 +48,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "9.0.x" + dotnet-version: "10.0.x" - name: Restore Dependencies run: dotnet restore src/Pulse/Pulse.csproj From bce49483cdd2820b6e6801a7c220b781ff9054e6 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:27:36 +0200 Subject: [PATCH 045/105] prepare (commented) profiling once for once it becomes functional. --- .../workflows/publish-current-release.yaml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/publish-current-release.yaml b/.github/workflows/publish-current-release.yaml index abfe147..18f02b1 100644 --- a/.github/workflows/publish-current-release.yaml +++ b/.github/workflows/publish-current-release.yaml @@ -53,6 +53,57 @@ jobs: - name: Restore Dependencies run: dotnet restore src/Pulse/Pulse.csproj + # - name: Publish (generate PGO data) + # run: dotnet publish src/Pulse/Pulse.csproj -c Release -o artifacts /p:IlcGeneratePgoData=true + # env: + # DOTNET_ROOT: ${{ env.DOTNET_ROOT }} + + # - name: Save artifact directory path (Unix) + # if: matrix.os != 'windows-latest' + # run: echo "executableDirectory=$(pwd)/artifacts" >> $GITHUB_ENV + # shell: bash + + # - name: Save artifact directory path (Windows) + # if: matrix.os == 'windows-latest' + # shell: pwsh + # run: | + # $artifacts = Join-Path (pwd).Path 'artifacts' + # Add-Content -Path $env:GITHUB_ENV -Value "executableDirectory=$artifacts" + + # - name: Profile with static PGO (Unix) + # if: matrix.os != 'windows-latest' + # working-directory: profiling + # shell: bash + # env: + # DOTNET_ROOT: ${{ env.DOTNET_ROOT }} + # run: | + # dotnet run server.cs & + # server_pid=$! + # trap 'kill $server_pid >/dev/null 2>&1 || true' EXIT + # dotnet run runner.cs -- --directory-path "${{ env.executableDirectory }}" + # kill $server_pid >/dev/null 2>&1 || true + # wait $server_pid || true + + # - name: Profile with static PGO (Windows) + # if: matrix.os == 'windows-latest' + # working-directory: profiling + # shell: pwsh + # env: + # DOTNET_ROOT: ${{ env.DOTNET_ROOT }} + # run: | + # $server = Start-Process dotnet -ArgumentList 'run','server.cs' -PassThru + # try { + # dotnet run runner.cs -- --directory-path $env:executableDirectory + # } finally { + # Stop-Process -Id $server.Id -ErrorAction SilentlyContinue -Force + # Wait-Process -Id $server.Id -ErrorAction SilentlyContinue + # } + + # - name: Publish + # run: dotnet publish src/Pulse/Pulse.csproj -c Release -o publish /p:IlcUsePgoData=./artifacts/Program.pgo + # env: + # DOTNET_ROOT: ${{ env.DOTNET_ROOT }} + - name: Publish run: dotnet publish src/Pulse/Pulse.csproj -c Release -o publish env: From 55c02caf1e7683a5a566022256f778540272438d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:59:13 +0200 Subject: [PATCH 046/105] Better command descriptions --- src/Pulse/Core/Commands.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 97fce31..44cb5fa 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -138,7 +138,7 @@ public static int TermsOfUse() { } /// - /// Generate a json schema file + /// Generate a json schema for a request file. /// /// -d, Configures in which directory [will default to current] /// @@ -156,7 +156,7 @@ public static async Task GetSchema(string? directory = null, CancellationTo } /// - /// Generate sample request file + /// Generate sample request file. /// /// -d, Configures in which directory [will default to current] /// @@ -171,7 +171,7 @@ public static async Task GetSample(string? directory = null, CancellationTo } /// - /// Prints the configuration + /// Prints the configuration. /// /// /// From fd54da9470f8018187dd3daacaefeeb5c5751765 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:59:22 +0200 Subject: [PATCH 047/105] Added cli-schema command --- src/Pulse/Core/CliSchemaCommand.cs | 24 ++++++++++++++++++++++++ src/Pulse/Program.cs | 13 ++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/Pulse/Core/CliSchemaCommand.cs diff --git a/src/Pulse/Core/CliSchemaCommand.cs b/src/Pulse/Core/CliSchemaCommand.cs new file mode 100644 index 0000000..93e2246 --- /dev/null +++ b/src/Pulse/Core/CliSchemaCommand.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +using ConsoleAppFramework; + +namespace Pulse.Core; + +internal sealed class CliSchemaCommand { + private readonly ConsoleApp.ConsoleAppBuilder _app; + + public CliSchemaCommand(ConsoleApp.ConsoleAppBuilder app) { + _app = app; + } + + /// + /// Returns the usage schema for the app in JSON format. + /// + /// + public int Command() { + CommandHelpDefinition[] schema = _app.GetCliSchema(); + ReadOnlySpan json = JsonSerializer.Serialize(schema, CliSchemaJsonSerializerContext.Default.CommandHelpDefinitionArray); + Console.WriteLine(json); + return 0; + } +} \ No newline at end of file diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 0f856f1..7fd3ab2 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,6 +1,4 @@ -using System.Text.Json; - -using ConsoleAppFramework; +using ConsoleAppFramework; using Pulse.Core; @@ -16,11 +14,8 @@ app.Add("check-for-updates", Commands.CheckForUpdates); app.Add("terms-of-use", Commands.TermsOfUse); -app.Add("cli-schema", () => { - CommandHelpDefinition[] schema = app.GetCliSchema(); - ReadOnlySpan json = JsonSerializer.Serialize(schema, CliSchemaJsonSerializerContext.Default.CommandHelpDefinitionArray); - Console.WriteLine(json); - return 0; -}); +var schemaCommand = new CliSchemaCommand(app); + +app.Add("cli-schema", schemaCommand.Command); await app.RunAsync(args).ConfigureAwait(false); \ No newline at end of file From 2c87e2420eb59637bd9b2e545f2a96cb2045ac1f Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:59:33 +0200 Subject: [PATCH 048/105] Updated history and changelog --- Changelog.md | 12 +++++++----- History.md | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Changelog.md b/Changelog.md index 93a471d..531fb86 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,15 +2,17 @@ - Updated `PrettyConsole` to latest version to use higher perf APIs. - Moved from using `Sharpify.CommandLineInterface` to `ConsoleAppFramework` for better perf and less verbose code. +- Dropped `Sharpify` dependency. - Many different internals were optimized to provide higher stability and performance. - `ExecutionMode` is no longer used, and the options were unified: - To use `Sequential` mode, simply set `-c| --connections` to 1. - By default `Parallel` number will be used and `connections` will be set to the number of requests. - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. -- Many outputs show now be more consistent and artifact free, including when updating output is interrupted (like when press CTRL+C). +- Many outputs are now more consistent and artifact free, including when the output is interrupted (like when press CTRL+C). +- Added `cli-schema` command prints the usage schema for the app in JSON format - Useful for LLM's and AGENTS. - Compilations options were refined to produce a even more purpose fit executable. - - The binary size should smaller. - - Startup times should be better. - - Potential delays due to GC should not be much less frequent. - - Hot-paths should perform even faster due to multi-level perf analysis. + - Smaller output binary size. + - Shorter startup times. + - Much less memory allocations and lower GC pressure. + - De-abstraction of hot-paths should now results in overall performance increase. diff --git a/History.md b/History.md index 8eb77a8..337c1ca 100644 --- a/History.md +++ b/History.md @@ -4,18 +4,20 @@ - Updated `PrettyConsole` to latest version to use higher perf APIs. - Moved from using `Sharpify.CommandLineInterface` to `ConsoleAppFramework` for better perf and less verbose code. +- Dropped `Sharpify` dependency. - Many different internals were optimized to provide higher stability and performance. - `ExecutionMode` is no longer used, and the options were unified: - To use `Sequential` mode, simply set `-c| --connections` to 1. - By default `Parallel` number will be used and `connections` will be set to the number of requests. - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. -- Many outputs show now be more consistent and artifact free, including when updating output is interrupted (like when press CTRL+C). +- Many outputs are now more consistent and artifact free, including when the output is interrupted (like when press CTRL+C). +- Added `cli-schema` command prints the usage schema for the app in JSON format - Useful for LLM's and AGENTS. - Compilations options were refined to produce a even more purpose fit executable. - - The binary size should smaller. - - Startup times should be better. - - Potential delays due to GC should not be much less frequent. - - Hot-paths should perform even faster due to multi-level perf analysis. + - Smaller output binary size. + - Shorter startup times. + - Much less memory allocations and lower GC pressure. + - De-abstraction of hot-paths should now results in overall performance increase. ## Version 1.2.0.0 From b703523db66fb2d0d0373b89b11a3f83b60e08c5 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 13:59:44 +0200 Subject: [PATCH 049/105] Fix readme and update assets --- Readme.md | 37 +++++++++++++++++++------------------ assets/pulse-running.png | Bin 0 -> 54394 bytes assets/pulse-summary.png | Bin 0 -> 132911 bytes 3 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 assets/pulse-running.png create mode 100644 assets/pulse-summary.png diff --git a/Readme.md b/Readme.md index c2504a3..728c94a 100644 --- a/Readme.md +++ b/Readme.md @@ -5,14 +5,14 @@ Pulse is a general purpose, cross-platform, performance-oriented, command-line u ## Features - JSON based request configuration -- Support for using proxies +- Proxy support - Configurable concurrency via max connection limits and optional per-request delays - Supports all HTTP methods - Supports Headers - Support Content-Type and Body for POST, PUT, PATCH, and DELETE - Custom HTML generated outputs for easy inspection - Format JSON outputs -- Capture all response headers for debugging +- Captures all response headers for debugging And more! @@ -34,11 +34,11 @@ Pulse configuration.json During the execution, `Pulse` displays current metrics such as progress, success rate, ETA, and counts of responses from each of the 6 categories, i.e, 1xx, 2xx, 3xx, 4xx, 5xx, others. where `others` is essentially exceptions. -![Runtime metrics](https://github.com/user-attachments/assets/64f48192-f60e-4021-9df3-558af21c1bbd) +![Running](assets/pulse-running.png) After the execution (different configuration in this example), `Pulse` produces a detailed summary of the results -![Summary](https://github.com/user-attachments/assets/9a05ff6e-8d3d-46af-8509-013b5b1536a0) +![Summary](assets/pulse-summary.png) ### Setting up a configuration file @@ -115,23 +115,24 @@ Arguments: [0] Path to .json request details file [use "get-sample" if you don't have one] Options: - --json Try to format response content as JSON (Optional) - --raw Export raw results [without wrapping in custom HTML] (Optional) - -f|--full-equality Use full equality [slower] (Optional) - --no-export Don't export results (Optional) - -v|--verbose Display verbose output (Optional) - --no-op Print selected configuration but don't run (Optional) - -o|--output Output folder (Default: @"results") - -d|--delay Delay in milliseconds between requests (Default: -1) - -c|--connections Maximum number of parallel requests (Default: null) - -u|--url Override the url of the request (Default: null) - -n|--number Number of total requests (Default: 1) - -t|--timeout Timeout in milliseconds (Default: -1) + --json Try to format response content as JSON (Optional) + --raw Export raw results [without wrapping in custom HTML] (Optional) + -f, --full-equality Use full equality [slower] (Optional) + --no-export Don't export results (Optional) + -v, --verbose Display verbose output (Optional) + --no-op Print selected configuration but don't run (Optional) + -o, --output Output folder (Default: @"results") + -d, --delay Delay in milliseconds between requests (Default: -1) + -c, --connections Maximum number of parallel requests (Default: null) + -u, --url Override the url of the request (Default: null) + -n, --number Number of total requests (Default: 1) + -t, --timeout Timeout in milliseconds (Default: -1) Commands: check-for-updates Checks whether there is a new version out on GitHub releases. - get-sample Generate sample request file - get-schema Generate a json schema file + cli-schema Returns the usage schema for the app in JSON format. + get-sample Generate sample request file. + get-schema Generate a json schema for a request file. terms-of-use Print the terms of use. ``` diff --git a/assets/pulse-running.png b/assets/pulse-running.png new file mode 100644 index 0000000000000000000000000000000000000000..7285be1d9ebcdd46ec0d5346b920aeee97646de7 GIT binary patch literal 54394 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 z%0A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 literal 0 HcmV?d00001 diff --git a/assets/pulse-summary.png b/assets/pulse-summary.png new file mode 100644 index 0000000000000000000000000000000000000000..3e0a1bde42f4ecc0582e77c054d55e60fea83dce GIT binary patch literal 132911 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≥ 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 Date: Sat, 22 Nov 2025 10:09:01 +0200 Subject: [PATCH 050/105] Added formatting abstraction --- src/Pulse/Models/IOutputFormatter{T}.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/Pulse/Models/IOutputFormatter{T}.cs diff --git a/src/Pulse/Models/IOutputFormatter{T}.cs b/src/Pulse/Models/IOutputFormatter{T}.cs new file mode 100644 index 0000000..11272bb --- /dev/null +++ b/src/Pulse/Models/IOutputFormatter{T}.cs @@ -0,0 +1,25 @@ +namespace Pulse.Models; + +internal interface IOutputFormatter where T : allows ref struct { + void Output(OutputFormat format) { + switch (format) { + case OutputFormat.PlainText: + OutputAsPlainText(); + break; + case OutputFormat.JSON: + OutputAsJson(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(format)); + } + } + + abstract void OutputAsPlainText(); + + abstract void OutputAsJson(); +} + +internal enum OutputFormat { + PlainText, + JSON +} \ No newline at end of file From 355ef2dbeef1db4420d876aa98bd8f8c1a177291 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 10:44:20 +0200 Subject: [PATCH 051/105] Improved formatting abstraction and added converter --- src/Pulse/Core/Helper.cs | 10 ++++------ src/Pulse/Models/IOutputFormatter{T}.cs | 26 +++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index 639abee..84bc377 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -2,6 +2,7 @@ using System.Numerics; using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; @@ -9,12 +10,10 @@ namespace Pulse.Core; /// Helper class ///
internal static class Helper { - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static double Percentage(T current, T total) where T : INumberBase { return double.CreateChecked(current / total); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TimeSpan GetETA(double percentage, TimeSpan elapsed) { if (percentage <= 0) return TimeSpan.MaxValue; if (percentage >= 1) return TimeSpan.Zero; @@ -22,6 +21,9 @@ public static TimeSpan GetETA(double percentage, TimeSpan elapsed) { return rem * elapsed; } + // Returns an OutputFormat based on the llm parameter + public static OutputFormat OutputFormatFromBool(bool llm = false) + => llm ? OutputFormat.JSON : OutputFormat.PlainText; /// /// Returns a text color based on percentage @@ -60,7 +62,6 @@ public static ConsoleColor GetStatusCodeBasedColor(int statusCode) { /// /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static ConsoleColor GetMethodBasedColor(string method) => method switch { "GET" => Green, @@ -74,7 +75,6 @@ public static ConsoleColor GetMethodBasedColor(string method) ///
/// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy proxy) { if (proxy.IgnoreSSL) { #pragma warning disable CA5359 // Do Not Disable Certificate Validation @@ -87,7 +87,6 @@ public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy p /// Prints the exception /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void PrintException(this StrippedException e) { Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Exception type: {ConsoleColor.Default}{e.Type}"); Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Message: {ConsoleColor.Default}{e.Message}"); @@ -110,7 +109,6 @@ public static void PrintException(this StrippedException e) { /// /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string? AddExceptionDetail(Exception exception) { switch (exception) { case HttpRequestException: { diff --git a/src/Pulse/Models/IOutputFormatter{T}.cs b/src/Pulse/Models/IOutputFormatter{T}.cs index 11272bb..93f03b9 100644 --- a/src/Pulse/Models/IOutputFormatter{T}.cs +++ b/src/Pulse/Models/IOutputFormatter{T}.cs @@ -1,25 +1,27 @@ namespace Pulse.Models; internal interface IOutputFormatter where T : allows ref struct { - void Output(OutputFormat format) { + abstract void OutputAsPlainText(); + + abstract void OutputAsJson(); +} + +internal enum OutputFormat { + PlainText, + JSON +} + +internal static class OutputFormatterExtensions { + internal static void Output(this T value, OutputFormat format) where T : IOutputFormatter, allows ref struct { switch (format) { case OutputFormat.PlainText: - OutputAsPlainText(); + value.OutputAsPlainText(); break; case OutputFormat.JSON: - OutputAsJson(); + value.OutputAsJson(); break; default: throw new ArgumentOutOfRangeException(nameof(format)); } } - - abstract void OutputAsPlainText(); - - abstract void OutputAsJson(); -} - -internal enum OutputFormat { - PlainText, - JSON } \ No newline at end of file From 39a0448de203aef9ee6e315f6732c7a9fc6cbb23 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 10:44:36 +0200 Subject: [PATCH 052/105] Implement global options to support OutputFormatter --- src/Pulse/Models/GlobalOptions.cs | 7 +++++++ src/Pulse/Program.cs | 6 ++++++ 2 files changed, 13 insertions(+) create mode 100644 src/Pulse/Models/GlobalOptions.cs diff --git a/src/Pulse/Models/GlobalOptions.cs b/src/Pulse/Models/GlobalOptions.cs new file mode 100644 index 0000000..037f787 --- /dev/null +++ b/src/Pulse/Models/GlobalOptions.cs @@ -0,0 +1,7 @@ +namespace Pulse.Models; + +/// +/// Global options that can be used across commands. +/// +/// The output format to use +internal record GlobalOptions(OutputFormat Format); \ No newline at end of file diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 7fd3ab2..48bbca9 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,6 +1,7 @@ using ConsoleAppFramework; using Pulse.Core; +using Pulse.Models; ConsoleApp.Version = Commands.VERSION; @@ -8,6 +9,11 @@ app.UseFilter(); +app.ConfigureGlobalOptions((ref builder) => { + var llm = builder.AddGlobalOption("--llm", description: "Output using structured JSON", defaultValue: false); + return new GlobalOptions(Helper.OutputFormatFromBool(llm)); +}); + app.Add("", Commands.Root); app.Add("get-sample", Commands.GetSample); app.Add("get-schema", Commands.GetSchema); From 5ddc3f1a8a8f4ff5e7a53a3b473d63a56bcfa55e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 10:44:46 +0200 Subject: [PATCH 053/105] Update dependencies --- src/Pulse/Pulse.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 4d758b3..8b3fcde 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -28,11 +28,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From d0c0d34741489e1a0f2731e1bc65484732d20b67 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 10:45:13 +0200 Subject: [PATCH 054/105] Add TermsOfService model --- src/Pulse/Models/TermsOfServiceModel.cs | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Pulse/Models/TermsOfServiceModel.cs diff --git a/src/Pulse/Models/TermsOfServiceModel.cs b/src/Pulse/Models/TermsOfServiceModel.cs new file mode 100644 index 0000000..7fb3892 --- /dev/null +++ b/src/Pulse/Models/TermsOfServiceModel.cs @@ -0,0 +1,28 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pulse.Models; + +internal readonly ref partial struct TermsOfServiceModel : IOutputFormatter { + private static ImmutableArray Lines => ImmutableArray.Create([ + "By using this tool you agree to take full responsibility for the consequences of its use.", + "Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws.", + "The developers assume no liability and are not responsible for any misuse or damage caused by this program." + ]); + + public void OutputAsJson() { + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); + JsonSerializer.Serialize(writer, Lines, JsonContext.Default.ImmutableArrayString); + writer.Flush(); + } + + public void OutputAsPlainText() { + foreach (var line in Lines) { + Console.WriteLineInterpolated($"{line}"); + } + } + + [JsonSerializable(typeof(ImmutableArray))] + private partial class JsonContext : JsonSerializerContext; +} \ No newline at end of file From f11eaed8617304cc78631d51969c62bd5e23e08b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 10:45:22 +0200 Subject: [PATCH 055/105] Use termsOfService model --- src/Pulse/Core/Commands.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 44cb5fa..8ce529e 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -5,6 +5,7 @@ using ConsoleAppFramework; using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; @@ -124,16 +125,12 @@ public static async Task CheckForUpdates(CancellationToken ct = default) { /// Print the terms of use. /// /// - public static int TermsOfUse() { - Console.WriteLineInterpolated( - $""" - By using this tool you agree to take full responsibility for the consequences of its use. - - Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's - responsibility to obey all applicable local, state and federal laws. - Developers assume no liability and are not responsible for any misuse or damage caused by this program. - """ - ); + public static int TermsOfUse(ConsoleAppContext context) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + var model = new TermsOfServiceModel(); + model.Output(options.Format); return 0; } From 52e079a3ac66cad4e1daaf25e522c30fcb5eedbe Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:09:35 +0200 Subject: [PATCH 056/105] Removed unnecessary generic --- .../Models/{IOutputFormatter{T}.cs => IOutputFormatter.cs} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/Pulse/Models/{IOutputFormatter{T}.cs => IOutputFormatter.cs} (80%) diff --git a/src/Pulse/Models/IOutputFormatter{T}.cs b/src/Pulse/Models/IOutputFormatter.cs similarity index 80% rename from src/Pulse/Models/IOutputFormatter{T}.cs rename to src/Pulse/Models/IOutputFormatter.cs index 93f03b9..f2dc8d8 100644 --- a/src/Pulse/Models/IOutputFormatter{T}.cs +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -1,6 +1,6 @@ namespace Pulse.Models; -internal interface IOutputFormatter where T : allows ref struct { +internal interface IOutputFormatter { abstract void OutputAsPlainText(); abstract void OutputAsJson(); @@ -12,7 +12,7 @@ internal enum OutputFormat { } internal static class OutputFormatterExtensions { - internal static void Output(this T value, OutputFormat format) where T : IOutputFormatter, allows ref struct { + internal static void Output(this T value, OutputFormat format) where T : IOutputFormatter, allows ref struct { switch (format) { case OutputFormat.PlainText: value.OutputAsPlainText(); From a8c29aff56bf7434c4127f3467e402b1ca8a2348 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:09:44 +0200 Subject: [PATCH 057/105] Added summary models --- src/Pulse/Models/MinMeanMax.cs | 7 +++++ src/Pulse/Models/SummaryModel.cs | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/Pulse/Models/MinMeanMax.cs create mode 100644 src/Pulse/Models/SummaryModel.cs diff --git a/src/Pulse/Models/MinMeanMax.cs b/src/Pulse/Models/MinMeanMax.cs new file mode 100644 index 0000000..0755e70 --- /dev/null +++ b/src/Pulse/Models/MinMeanMax.cs @@ -0,0 +1,7 @@ +namespace Pulse.Models; + +internal readonly struct MinMeanMax { + public required double Min { get; init; } + public required double Mean { get; init; } + public required double Max { get; init; } +} \ No newline at end of file diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs new file mode 100644 index 0000000..6293df2 --- /dev/null +++ b/src/Pulse/Models/SummaryModel.cs @@ -0,0 +1,45 @@ +using System.Net; +using System.Text.Json; + +using Pulse.Core; + +namespace Pulse.Models; + +internal readonly struct SummaryModel : IOutputFormatter { + public required int RequestCount { get; init; } + public required int ConcurrentConnections { get; init; } + public required TimeSpan TotalDuration { get; init; } + public required double SuccessRate { get; init; } + public required MinMeanMax Latency { get; init; } + public required int LatencyOutliersRemoved { get; init; } + public required MinMeanMax ContentSize { get; init; } + public required double ThroughputBytesPerSecond { get; init; } + public required Dictionary StatusCodeCounts { get; init; } + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.SummaryModel); + } + + public void OutputAsPlainText() { + Console.WriteLineInterpolated($"Request count: {Yellow}{RequestCount}"); + Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); + Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(SuccessRate)}{SuccessRate}%"); + Console.WriteLineInterpolated($"Latency: Min: {Green}{Latency.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{Latency.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{Latency.Max:0.##}ms"); + if (LatencyOutliersRemoved != 0) { + Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); + } + Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSize.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSize.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSize.Max:bytes}"); + Console.WriteLineInterpolated($"Total throughput: {Yellow}{ThroughputBytesPerSecond:bytes}/s"); + Console.WriteLineInterpolated($"Status codes:"); + foreach (var kvp in StatusCodeCounts.OrderBy(static s => (int)s.Key)) { + var key = (int)kvp.Key; + if (key is 0) { + Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); + } else { + Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); + } + } + Console.NewLine(); + } +} \ No newline at end of file From 174b69abbe258c6245922d944b1ff23b23248431 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:10:01 +0200 Subject: [PATCH 058/105] Use one serializer context for models --- src/Pulse/Models/ModelsJsonContext.cs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/Pulse/Models/ModelsJsonContext.cs diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs new file mode 100644 index 0000000..bad8871 --- /dev/null +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -0,0 +1,21 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Pulse.Models; + +[JsonSerializable(typeof(SummaryModel))] +[JsonSerializable(typeof(MinMeanMax))] +[JsonSerializable(typeof(ImmutableArray))] +internal partial class ModelsJsonContext : JsonSerializerContext; + +internal static class JsonSerializerExtensions { + extension(JsonSerializer) { + internal static void ToConsoleOut(in T value, JsonTypeInfo jsonTypeInfo) { + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); + JsonSerializer.Serialize(writer, value, jsonTypeInfo); + writer.Flush(); + } + } +} \ No newline at end of file From 056bc21902b7749d38c2d8a4990a285058011a5e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:10:11 +0200 Subject: [PATCH 059/105] Simplify model --- src/Pulse/Models/TermsOfServiceModel.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Pulse/Models/TermsOfServiceModel.cs b/src/Pulse/Models/TermsOfServiceModel.cs index 7fb3892..246c0ab 100644 --- a/src/Pulse/Models/TermsOfServiceModel.cs +++ b/src/Pulse/Models/TermsOfServiceModel.cs @@ -1,10 +1,9 @@ using System.Collections.Immutable; using System.Text.Json; -using System.Text.Json.Serialization; namespace Pulse.Models; -internal readonly ref partial struct TermsOfServiceModel : IOutputFormatter { +internal readonly struct TermsOfServiceModel : IOutputFormatter { private static ImmutableArray Lines => ImmutableArray.Create([ "By using this tool you agree to take full responsibility for the consequences of its use.", "Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws.", @@ -12,9 +11,7 @@ namespace Pulse.Models; ]); public void OutputAsJson() { - using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); - JsonSerializer.Serialize(writer, Lines, JsonContext.Default.ImmutableArrayString); - writer.Flush(); + JsonSerializer.ToConsoleOut(Lines, ModelsJsonContext.Default.ImmutableArrayString); } public void OutputAsPlainText() { @@ -22,7 +19,4 @@ public void OutputAsPlainText() { Console.WriteLineInterpolated($"{line}"); } } - - [JsonSerializable(typeof(ImmutableArray))] - private partial class JsonContext : JsonSerializerContext; } \ No newline at end of file From 00a86f78d4edfc70df4ba3f73038741f5ca9cf0f Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:30:02 +0200 Subject: [PATCH 060/105] Moved models to correct folder --- src/Pulse/Configuration/DefaultJsonContext.cs | 1 + src/Pulse/Configuration/InputJsonContext.cs | 1 + src/Pulse/Core/Exporter.cs | 1 + src/Pulse/Core/IPulseMonitor.cs | 1 + src/Pulse/Core/Pulse.cs | 1 + src/Pulse/Core/PulseHttpClientFactory.cs | 2 ++ src/Pulse/Core/PulseMonitor.cs | 1 + src/Pulse/Core/PulseSummary.cs | 1 + src/Pulse/Core/VerbosePulseMonitor.cs | 1 + src/Pulse/{Core => Models}/PaddedULong.cs | 2 +- src/Pulse/{Core => Models}/PulseResult.cs | 2 +- src/Pulse/{Core => Models}/RawFailure.cs | 2 +- src/Pulse/{Core => Models}/ReleaseInfo.cs | 2 +- src/Pulse/{Core => Models}/RequestDetails.cs | 2 +- src/Pulse/{Core => Models}/Response.cs | 2 +- 15 files changed, 16 insertions(+), 6 deletions(-) rename src/Pulse/{Core => Models}/PaddedULong.cs (85%) rename src/Pulse/{Core => Models}/PulseResult.cs (95%) rename src/Pulse/{Core => Models}/RawFailure.cs (96%) rename src/Pulse/{Core => Models}/ReleaseInfo.cs (92%) rename src/Pulse/{Core => Models}/RequestDetails.cs (99%) rename src/Pulse/{Core => Models}/Response.cs (99%) diff --git a/src/Pulse/Configuration/DefaultJsonContext.cs b/src/Pulse/Configuration/DefaultJsonContext.cs index 4d69d64..4a3b501 100644 --- a/src/Pulse/Configuration/DefaultJsonContext.cs +++ b/src/Pulse/Configuration/DefaultJsonContext.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using Pulse.Core; +using Pulse.Models; namespace Pulse.Configuration; diff --git a/src/Pulse/Configuration/InputJsonContext.cs b/src/Pulse/Configuration/InputJsonContext.cs index a35bb53..7159734 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using Pulse.Core; +using Pulse.Models; namespace Pulse.Configuration; diff --git a/src/Pulse/Core/Exporter.cs b/src/Pulse/Core/Exporter.cs index d337e73..c957224 100644 --- a/src/Pulse/Core/Exporter.cs +++ b/src/Pulse/Core/Exporter.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index 327010e..b8ca94e 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -3,6 +3,7 @@ using System.Text; using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index edc7c0a..c4e908e 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -1,4 +1,5 @@ using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index 37a1c7e..860e8da 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -1,5 +1,7 @@ using System.Net; +using Pulse.Models; + namespace Pulse.Core; /// diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index ff5d513..064fb88 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using Pulse.Configuration; +using Pulse.Models; using static Pulse.Core.IPulseMonitor; diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 9d8082b..b13503d 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -4,6 +4,7 @@ using System.Runtime.Intrinsics; using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index a540018..7d3269a 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -3,6 +3,7 @@ using System.Net; using Pulse.Configuration; +using Pulse.Models; using static Pulse.Core.IPulseMonitor; diff --git a/src/Pulse/Core/PaddedULong.cs b/src/Pulse/Models/PaddedULong.cs similarity index 85% rename from src/Pulse/Core/PaddedULong.cs rename to src/Pulse/Models/PaddedULong.cs index 41e0bd2..ac1fcdf 100644 --- a/src/Pulse/Core/PaddedULong.cs +++ b/src/Pulse/Models/PaddedULong.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Pulse.Core; +namespace Pulse.Models; [StructLayout(LayoutKind.Sequential, Size = 64)] internal struct PaddedULong { diff --git a/src/Pulse/Core/PulseResult.cs b/src/Pulse/Models/PulseResult.cs similarity index 95% rename from src/Pulse/Core/PulseResult.cs rename to src/Pulse/Models/PulseResult.cs index 4393285..95f2ca4 100644 --- a/src/Pulse/Core/PulseResult.cs +++ b/src/Pulse/Models/PulseResult.cs @@ -1,6 +1,6 @@ using System.Collections.Concurrent; -namespace Pulse.Core; +namespace Pulse.Models; /// /// Result of pulse (complete test) diff --git a/src/Pulse/Core/RawFailure.cs b/src/Pulse/Models/RawFailure.cs similarity index 96% rename from src/Pulse/Core/RawFailure.cs rename to src/Pulse/Models/RawFailure.cs index 4a5eaeb..4261740 100644 --- a/src/Pulse/Core/RawFailure.cs +++ b/src/Pulse/Models/RawFailure.cs @@ -1,6 +1,6 @@ using Pulse.Configuration; -namespace Pulse.Core; +namespace Pulse.Models; /// /// Represents a serializable way to display non successful response information when using diff --git a/src/Pulse/Core/ReleaseInfo.cs b/src/Pulse/Models/ReleaseInfo.cs similarity index 92% rename from src/Pulse/Core/ReleaseInfo.cs rename to src/Pulse/Models/ReleaseInfo.cs index 2db9f57..cd75895 100644 --- a/src/Pulse/Core/ReleaseInfo.cs +++ b/src/Pulse/Models/ReleaseInfo.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Pulse.Core; +namespace Pulse.Models; /// /// Release information diff --git a/src/Pulse/Core/RequestDetails.cs b/src/Pulse/Models/RequestDetails.cs similarity index 99% rename from src/Pulse/Core/RequestDetails.cs rename to src/Pulse/Models/RequestDetails.cs index ba9f37f..18efedd 100644 --- a/src/Pulse/Core/RequestDetails.cs +++ b/src/Pulse/Models/RequestDetails.cs @@ -3,7 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Pulse.Core; +namespace Pulse.Models; /// /// Request details diff --git a/src/Pulse/Core/Response.cs b/src/Pulse/Models/Response.cs similarity index 99% rename from src/Pulse/Core/Response.cs rename to src/Pulse/Models/Response.cs index d9cc66f..781f9ed 100644 --- a/src/Pulse/Core/Response.cs +++ b/src/Pulse/Models/Response.cs @@ -2,7 +2,7 @@ using Pulse.Configuration; -namespace Pulse.Core; +namespace Pulse.Models; /// /// The model used for response From cb11ef9670eb1635f86f09c9dc43c82cd3c600ef Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 13:55:38 +0200 Subject: [PATCH 061/105] Implemented Summary format --- src/Pulse/Configuration/Parameters.cs | 31 +++++--- src/Pulse/Core/Pulse.cs | 6 +- src/Pulse/Core/PulseSummary.cs | 106 +++++++++++++++----------- src/Pulse/Models/SummaryModel.cs | 4 +- 4 files changed, 82 insertions(+), 65 deletions(-) diff --git a/src/Pulse/Configuration/Parameters.cs b/src/Pulse/Configuration/Parameters.cs index 7d1d3e6..ea5be66 100644 --- a/src/Pulse/Configuration/Parameters.cs +++ b/src/Pulse/Configuration/Parameters.cs @@ -1,3 +1,5 @@ +using Pulse.Models; + namespace Pulse.Configuration; /// @@ -5,57 +7,62 @@ namespace Pulse.Configuration; /// internal record ParametersBase { /// - /// Sets the number of requests (default = 100) + /// Sets the number of requests (default = 100). /// public int Requests { get; set; } = 1; /// - /// Sets the timeout in milliseconds + /// Sets the timeout in milliseconds. /// public int TimeoutInMs { get; set; } = -1; /// - /// The delay between requests in milliseconds + /// The delay between requests in milliseconds. /// public int DelayInMs { get; set; } /// - /// Sets the maximum connections + /// Sets the maximum connections. /// public int Connections { get; init; } = 1; /// - /// Attempt to format response content as JSON + /// Attempt to format response content as JSON. /// public bool FormatJson { get; init; } /// - /// Indicating whether to export raw results (without wrapping in custom html) + /// Indicating whether to export raw results (without wrapping in custom html). /// public bool ExportRaw { get; init; } /// - /// Indicating whether to export results + /// Indicating whether to export results. /// public bool Export { get; init; } = true; /// - /// Check full equality for response content + /// Check full equality for response content. /// public bool UseFullEquality { get; init; } /// - /// Display configuration and exit + /// Display configuration and exit. /// public bool NoOp { get; init; } /// - /// Display verbose output (adds more metrics) + /// Display verbose output (adds more metrics). /// public bool Verbose { get; init; } /// - /// Output folder + /// The output format to use. + /// + public OutputFormat OutputFormat { get; init; } + + /// + /// Output folder. /// public string OutputFolder { get; init; } = "results"; } @@ -65,7 +72,7 @@ internal record ParametersBase { /// internal sealed record Parameters : ParametersBase { /// - /// Application-wide cancellation token + /// Application-wide cancellation token. /// public readonly CancellationToken CancellationToken; diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index c4e908e..01a5717 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -48,10 +48,6 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); - var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, result, requestDetails.Request.GetRequestLength()); - - if (exportRequired) { - await PulseSummary.ExportUniqueRequestsAsync(parameters, uniqueRequests, cancellationToken).ConfigureAwait(false); - } + await PulseSummary.SummarizeAsync(parameters, result, requestDetails.Request.GetRequestLength()).ConfigureAwait(false); } } diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index b13503d..bd4393c 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -16,10 +16,12 @@ internal static class PulseSummary { /// Produces a summary, and saves unique requests if export is enabled. /// /// Value indicating whether export is required, and the requests to export (null if not required) - public static (bool exportRequired, HashSet uniqueRequests) Summarize(Parameters parameters, PulseResult pulseResult, long requestSizeInBytes) { + public static async ValueTask SummarizeAsync(Parameters parameters, PulseResult pulseResult, long requestSizeInBytes) { var completed = pulseResult.Results.Count; + if (completed is 1) { - return SummarizeSingle(parameters, pulseResult); + await SummarizeSingleAsync(parameters, pulseResult).ConfigureAwait(false); + return; } HashSet uniqueRequests = parameters.Export @@ -67,59 +69,71 @@ public static (bool exportRequired, HashSet uniqueRequests) Summarize( // Clear "cross referencing results..." Console.ClearNextLines(1, OutputPipe.Error); - Console.WriteLineInterpolated($"Request count: {Yellow}{completed}"); - Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{peakConcurrentConnections}"); - Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:duration}"); - Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(pulseResult.SuccessRate)}{pulseResult.SuccessRate}%"); - Console.WriteLineInterpolated($"Latency: Min: {Green}{latencySummary.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{latencySummary.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{latencySummary.Max:0.##}ms"); - if (latencySummary.Removed != 0) { - Console.WriteLineInterpolated($" (Removed {DarkYellow}{latencySummary.Removed}{ConsoleColor.Default} {(latencySummary.Removed == 1 ? "outlier" : "outliers")})"); - } - Console.WriteLineInterpolated($"Content Size: Min: {Green}{sizeSummary.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{sizeSummary.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{sizeSummary.Max:bytes}"); - Console.WriteLineInterpolated($"Total throughput: {Yellow}{throughput:bytes}/s"); - Console.WriteLineInterpolated($"Status codes:"); - foreach (var kvp in statusCounter.OrderBy(static s => (int)s.Key)) { - var key = (int)kvp.Key; - if (key is 0) { - Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); - } else { - Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); - } - } - Console.NewLine(); + var output = new SummaryModel { + RequestCount = parameters.Requests, + ConcurrentConnections = peakConcurrentConnections, + TotalDuration = pulseResult.TotalDuration, + SuccessRate = pulseResult.SuccessRate, + LatencyInMilliseconds = new MinMeanMax { + Min = latencySummary.Min, + Mean = latencySummary.Mean, + Max = latencySummary.Max, + }, + LatencyOutliersRemoved = latencySummary.Removed, + ContentSize = new MinMeanMax { + Min = sizeSummary.Min, + Mean = sizeSummary.Mean, + Max = sizeSummary.Max + }, + ThroughputBytesPerSecond = throughput, + StatusCodeCounts = statusCounter + }; - return (parameters.Export, uniqueRequests); + if (parameters.Export) { + await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); + } } /// /// Produces a summary for a single result /// /// Value indicating whether export is required, and the requests to export (null if not required) - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static (bool exportRequired, HashSet uniqueRequests) SummarizeSingle(Parameters parameters, PulseResult pulseResult) { + internal static async ValueTask SummarizeSingleAsync(Parameters parameters, PulseResult pulseResult) { var result = pulseResult.Results.First(); - double duration = result.Latency.TotalMilliseconds; var statusCode = result.StatusCode; + var latency = result.Latency.TotalMilliseconds; + var size = (double)result.ContentLength; + var throughput = size / result.Latency.TotalSeconds; + + var output = new SummaryModel { + RequestCount = 1, + ConcurrentConnections = 1, + TotalDuration = pulseResult.TotalDuration, + SuccessRate = pulseResult.SuccessRate, + LatencyInMilliseconds = new MinMeanMax { + Min = latency, + Mean = latency, + Max = latency, + }, + LatencyOutliersRemoved = 0, + ContentSize = new MinMeanMax { + Min = size, + Mean = size, + Max = size + }, + ThroughputBytesPerSecond = throughput, + StatusCodeCounts = new Dictionary { + {statusCode, 1} + } + }; - Console.WriteLineInterpolated($"Request count: {Yellow}1"); - Console.WriteLineInterpolated($"Total duration: {Yellow}{pulseResult.TotalDuration:duration}"); - if ((int)statusCode is >= 200 and < 300) { - Console.WriteLineInterpolated($"Success: {Green}true"); - } else { - Console.WriteLineInterpolated($"Success: {Red}false"); - } - Console.WriteLineInterpolated($"Latency: {Green}{duration:0.##}ms"); - Console.WriteLineInterpolated($"Content Size: {Green}{(double)result.ContentLength:bytes}"); - if (statusCode is 0) { - Console.WriteLineInterpolated($"Status code: {Red}0 [Exception]"); - } else { - Console.WriteLineInterpolated($"Status code: {Helper.GetStatusCodeBasedColor((int)statusCode)}{statusCode}"); - } - Console.NewLine(); + output.Output(parameters.OutputFormat); - var uniqueRequests = new HashSet(1) { result }; + if (parameters.Export) { + var uniqueRequests = new HashSet(1) { result }; - return (parameters.Export, uniqueRequests); + await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); + } } @@ -237,7 +251,7 @@ internal static double Mean(ReadOnlySpan span) { /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests, CancellationToken token = default) { + internal static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests) { var count = uniqueRequests.Count; if (count is 0) { @@ -250,14 +264,14 @@ public static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSe Exporter.ClearFiles(directory); if (count is 1) { - await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, token).ConfigureAwait(false); + await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, parameters.CancellationToken).ConfigureAwait(false); Console.WriteLineInterpolated($"{Green}1{ConsoleColor.Default} unique response exported to {Yellow}{directory}"); return; } var options = new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount, - CancellationToken = token + CancellationToken = parameters.CancellationToken }; await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn).ConfigureAwait(false)).ConfigureAwait(false); diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs index 6293df2..3c34151 100644 --- a/src/Pulse/Models/SummaryModel.cs +++ b/src/Pulse/Models/SummaryModel.cs @@ -10,7 +10,7 @@ namespace Pulse.Models; public required int ConcurrentConnections { get; init; } public required TimeSpan TotalDuration { get; init; } public required double SuccessRate { get; init; } - public required MinMeanMax Latency { get; init; } + public required MinMeanMax LatencyInMilliseconds { get; init; } public required int LatencyOutliersRemoved { get; init; } public required MinMeanMax ContentSize { get; init; } public required double ThroughputBytesPerSecond { get; init; } @@ -25,7 +25,7 @@ public void OutputAsPlainText() { Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(SuccessRate)}{SuccessRate}%"); - Console.WriteLineInterpolated($"Latency: Min: {Green}{Latency.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{Latency.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{Latency.Max:0.##}ms"); + Console.WriteLineInterpolated($"Latency: Min: {Green}{LatencyInMilliseconds.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); if (LatencyOutliersRemoved != 0) { Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); } From 7631233e08a5c29bf28acfce2ade7e25db6fbabe Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 14:06:06 +0200 Subject: [PATCH 062/105] Better more accurate summary --- src/Pulse/Configuration/InputJsonContext.cs | 9 ++++----- src/Pulse/Core/Commands.cs | 5 ++--- src/Pulse/Core/Pulse.cs | 2 +- src/Pulse/Core/PulseSummary.cs | 17 ++++++++++++++--- src/Pulse/Models/ModelsJsonContext.cs | 1 + src/Pulse/Models/SummaryModel.cs | 2 ++ src/Pulse/Models/Target.cs | 6 ++++++ 7 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 src/Pulse/Models/Target.cs diff --git a/src/Pulse/Configuration/InputJsonContext.cs b/src/Pulse/Configuration/InputJsonContext.cs index 7159734..2b85ec3 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -1,7 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Pulse.Core; using Pulse.Models; namespace Pulse.Configuration; @@ -20,15 +19,15 @@ namespace Pulse.Configuration; [JsonSerializable(typeof(JsonElement))] internal partial class InputJsonContext : JsonSerializerContext { /// - /// Try to get request details from file + /// Try to get request details from file, do not attempt to use if returns false. /// /// /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static bool TryGetRequestDetailsFromFile(string path, out RequestDetails? details) { + public static bool TryGetRequestDetailsFromFile(string path, out RequestDetails details) { if (!File.Exists(path)) { - details = null; + details = null!; return false; } @@ -36,7 +35,7 @@ public static bool TryGetRequestDetailsFromFile(string path, out RequestDetails? var rd = JsonSerializer.Deserialize(json, Default.RequestDetails); if (rd is null) { - details = null; + details = null!; return false; } else { details = rd; diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 8ce529e..672030f 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -65,11 +65,11 @@ public static async Task Root([Argument] string requestFile, var requestFilePath = Path.GetFullPath(requestFile); - if (!InputJsonContext.TryGetRequestDetailsFromFile(requestFilePath, out var requestDetails)) { + if (!InputJsonContext.TryGetRequestDetailsFromFile(requestFilePath, out RequestDetails requestDetails)) { Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to retrieve and parse request file from {Markup.Underline}{Yellow}{requestFilePath}{Markup.ResetUnderline}"); return 1; } - ArgumentNullException.ThrowIfNull(requestDetails); + if (url is not null) { requestDetails.Request.Url = url; } @@ -81,7 +81,6 @@ public static async Task Root([Argument] string requestFile, return 0; } - Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(requestDetails.Request.Method.Method)}{requestDetails.Request.Method.Method}{ConsoleColor.Default} => {Markup.Underline}{requestDetails.Request.Url}{Markup.ResetUnderline}"); await Pulse.RunAsync(@params, requestDetails).ConfigureAwait(false); return 0; } diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 01a5717..78cb50b 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -48,6 +48,6 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); - await PulseSummary.SummarizeAsync(parameters, result, requestDetails.Request.GetRequestLength()).ConfigureAwait(false); + await PulseSummary.SummarizeAsync(parameters, requestDetails, result).ConfigureAwait(false); } } diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index bd4393c..eb9f798 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -16,11 +16,12 @@ internal static class PulseSummary { /// Produces a summary, and saves unique requests if export is enabled. /// /// Value indicating whether export is required, and the requests to export (null if not required) - public static async ValueTask SummarizeAsync(Parameters parameters, PulseResult pulseResult, long requestSizeInBytes) { + public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetails requestDetails, PulseResult pulseResult) { var completed = pulseResult.Results.Count; + var requestSizeInBytes = requestDetails.Request.GetRequestLength(); if (completed is 1) { - await SummarizeSingleAsync(parameters, pulseResult).ConfigureAwait(false); + await SummarizeSingleAsync(parameters, requestDetails, pulseResult).ConfigureAwait(false); return; } @@ -70,6 +71,10 @@ public static async ValueTask SummarizeAsync(Parameters parameters, PulseResult Console.ClearNextLines(1, OutputPipe.Error); var output = new SummaryModel { + Target = new Target { + HttpMethod = requestDetails.Request.Method.Method, + Url = requestDetails.Request.Url + }, RequestCount = parameters.Requests, ConcurrentConnections = peakConcurrentConnections, TotalDuration = pulseResult.TotalDuration, @@ -89,6 +94,8 @@ public static async ValueTask SummarizeAsync(Parameters parameters, PulseResult StatusCodeCounts = statusCounter }; + output.Output(parameters.OutputFormat); + if (parameters.Export) { await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); } @@ -98,7 +105,7 @@ public static async ValueTask SummarizeAsync(Parameters parameters, PulseResult /// Produces a summary for a single result /// /// Value indicating whether export is required, and the requests to export (null if not required) - internal static async ValueTask SummarizeSingleAsync(Parameters parameters, PulseResult pulseResult) { + internal static async ValueTask SummarizeSingleAsync(Parameters parameters, RequestDetails requestDetails, PulseResult pulseResult) { var result = pulseResult.Results.First(); var statusCode = result.StatusCode; var latency = result.Latency.TotalMilliseconds; @@ -106,6 +113,10 @@ internal static async ValueTask SummarizeSingleAsync(Parameters parameters, Puls var throughput = size / result.Latency.TotalSeconds; var output = new SummaryModel { + Target = new Target { + HttpMethod = requestDetails.Request.Method.Method, + Url = requestDetails.Request.Url + }, RequestCount = 1, ConcurrentConnections = 1, TotalDuration = pulseResult.TotalDuration, diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index bad8871..446708c 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -7,6 +7,7 @@ namespace Pulse.Models; [JsonSerializable(typeof(SummaryModel))] [JsonSerializable(typeof(MinMeanMax))] +[JsonSerializable(typeof(Target))] [JsonSerializable(typeof(ImmutableArray))] internal partial class ModelsJsonContext : JsonSerializerContext; diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs index 3c34151..671ab3a 100644 --- a/src/Pulse/Models/SummaryModel.cs +++ b/src/Pulse/Models/SummaryModel.cs @@ -6,6 +6,7 @@ namespace Pulse.Models; internal readonly struct SummaryModel : IOutputFormatter { + public required Target Target { get; init; } public required int RequestCount { get; init; } public required int ConcurrentConnections { get; init; } public required TimeSpan TotalDuration { get; init; } @@ -21,6 +22,7 @@ public void OutputAsJson() { } public void OutputAsPlainText() { + Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(Target.HttpMethod)}{Target.HttpMethod}{ConsoleColor.Default} => {Markup.Underline}{Target.Url}{Markup.ResetUnderline}"); Console.WriteLineInterpolated($"Request count: {Yellow}{RequestCount}"); Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); diff --git a/src/Pulse/Models/Target.cs b/src/Pulse/Models/Target.cs new file mode 100644 index 0000000..85950fc --- /dev/null +++ b/src/Pulse/Models/Target.cs @@ -0,0 +1,6 @@ +namespace Pulse.Models; + +internal readonly struct Target { + public readonly string HttpMethod { get; init; } + public readonly string Url { get; init; } +} \ No newline at end of file From 9b3ba5f0f1584cb3f4640a293e8964075935ccaa Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 14:13:43 +0200 Subject: [PATCH 063/105] Implement output formatting to run configuration --- src/Pulse/Core/Commands.cs | 49 ++------------- src/Pulse/Models/ModelsJsonContext.cs | 3 + .../{Configuration => Models}/Parameters.cs | 4 +- src/Pulse/Models/RunConfiguration.cs | 61 +++++++++++++++++++ 4 files changed, 70 insertions(+), 47 deletions(-) rename src/Pulse/{Configuration => Models}/Parameters.cs (97%) create mode 100644 src/Pulse/Models/RunConfiguration.cs diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 672030f..42606ae 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -172,50 +172,11 @@ public static async Task GetSample(string? directory = null, CancellationTo /// /// internal static void PrintConfiguration(Parameters parameters, RequestDetails requestDetails) { - ConsoleColor headerColor = Cyan; - ConsoleColor property = DarkGray; - ConsoleColor value = White; - - // Options - Console.WriteLineInterpolated($"{headerColor}Options:"); - Console.WriteLineInterpolated($"{property} Request count: {value}{parameters.Requests}"); - Console.WriteLineInterpolated($"{property} Concurrent connections: {value}{parameters.Connections}"); - Console.WriteLineInterpolated($"{property} Delay: {value}{parameters.DelayInMs}ms"); - Console.WriteLineInterpolated($"{property} Timeout: {value}{parameters.TimeoutInMs}"); - Console.WriteLineInterpolated($"{property} Export Raw: {value}{parameters.ExportRaw}"); - Console.WriteLineInterpolated($"{property} Format JSON: {value}{parameters.FormatJson}"); - Console.WriteLineInterpolated($"{property} Export Full Equality: {value}{parameters.UseFullEquality}"); - Console.WriteLineInterpolated($"{property} Export: {value}{parameters.Export}"); - Console.WriteLineInterpolated($"{property} Verbose: {value}{parameters.Verbose}"); - Console.WriteLineInterpolated($"{property} Output Folder: {value}{parameters.OutputFolder}"); - - // Request - Console.WriteLineInterpolated($"{headerColor}Request:"); - Console.WriteLineInterpolated($"{property} URL: {value}{requestDetails.Request.Url}"); - Console.WriteLineInterpolated($"{property} Method: {value}{requestDetails.Request.Method}"); - Console.WriteLineInterpolated($"{Yellow} Headers:"); - if (requestDetails.Request.Headers.Count > 0) { - foreach (var header in requestDetails.Request.Headers) { - if (header.Value is null) { - continue; - } - Console.WriteLineInterpolated($"{property} {header.Key}: {value}{header.Value.Value}"); - } - } - if (requestDetails.Request.Content.Body.HasValue) { - Console.WriteLineInterpolated($"{Yellow} Content:"); - Console.WriteLineInterpolated($"{property} ContentType: {value}{requestDetails.Request.Content.GetContentType()}"); - Console.WriteLineInterpolated($"{property} Body: {value}{requestDetails.Request.Content.Body}"); - } else { - Console.WriteLineInterpolated($"{property} Content: {value}none"); - } + var configuration = new RunConfiguration { + Parameters = parameters, + RequestDetails = requestDetails + }; - // Proxy - Console.WriteLineInterpolated($"{headerColor}Proxy:"); - Console.WriteLineInterpolated($"{property} Bypass: {value}{requestDetails.Proxy.Bypass}"); - Console.WriteLineInterpolated($"{property} Host: {value}{requestDetails.Proxy.Host}"); - Console.WriteLineInterpolated($"{property} Username: {value}{requestDetails.Proxy.Username}"); - Console.WriteLineInterpolated($"{property} Password: {value}{requestDetails.Proxy.Password}"); - Console.WriteLineInterpolated($"{property} Ignore SSL: {value}{requestDetails.Proxy.IgnoreSSL}"); + configuration.Output(parameters.OutputFormat); } } diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index 446708c..473ffc7 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -5,6 +5,9 @@ namespace Pulse.Models; +[JsonSerializable(typeof(RunConfiguration))] +[JsonSerializable(typeof(Parameters))] +[JsonSerializable(typeof(RequestDetails))] [JsonSerializable(typeof(SummaryModel))] [JsonSerializable(typeof(MinMeanMax))] [JsonSerializable(typeof(Target))] diff --git a/src/Pulse/Configuration/Parameters.cs b/src/Pulse/Models/Parameters.cs similarity index 97% rename from src/Pulse/Configuration/Parameters.cs rename to src/Pulse/Models/Parameters.cs index ea5be66..e4ea580 100644 --- a/src/Pulse/Configuration/Parameters.cs +++ b/src/Pulse/Models/Parameters.cs @@ -1,6 +1,4 @@ -using Pulse.Models; - -namespace Pulse.Configuration; +namespace Pulse.Models; /// /// Execution parameters diff --git a/src/Pulse/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs new file mode 100644 index 0000000..3662dde --- /dev/null +++ b/src/Pulse/Models/RunConfiguration.cs @@ -0,0 +1,61 @@ +using System.Text.Json; + +namespace Pulse.Models; + +internal readonly struct RunConfiguration : IOutputFormatter { + public Parameters Parameters { get; init; } + public RequestDetails RequestDetails { get; init; } + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.RunConfiguration); + } + + public void OutputAsPlainText() { + ConsoleColor headerColor = Cyan; + ConsoleColor property = DarkGray; + ConsoleColor value = White; + + // Options + Console.WriteLineInterpolated($"{headerColor}Options:"); + Console.WriteLineInterpolated($"{property} Request count: {value}{Parameters.Requests}"); + Console.WriteLineInterpolated($"{property} Concurrent connections: {value}{Parameters.Connections}"); + Console.WriteLineInterpolated($"{property} Delay: {value}{Parameters.DelayInMs}ms"); + Console.WriteLineInterpolated($"{property} Timeout: {value}{Parameters.TimeoutInMs}"); + Console.WriteLineInterpolated($"{property} Export Raw: {value}{Parameters.ExportRaw}"); + Console.WriteLineInterpolated($"{property} Format JSON: {value}{Parameters.FormatJson}"); + Console.WriteLineInterpolated($"{property} Export Full Equality: {value}{Parameters.UseFullEquality}"); + Console.WriteLineInterpolated($"{property} Export: {value}{Parameters.Export}"); + Console.WriteLineInterpolated($"{property} Verbose: {value}{Parameters.Verbose}"); + Console.WriteLineInterpolated($"{property} OutputFormat: {value}{Parameters.OutputFormat}"); + Console.WriteLineInterpolated($"{property} Output Folder: {value}{Parameters.OutputFolder}"); + + // Request + Console.WriteLineInterpolated($"{headerColor}Request:"); + Console.WriteLineInterpolated($"{property} URL: {value}{RequestDetails.Request.Url}"); + Console.WriteLineInterpolated($"{property} Method: {value}{RequestDetails.Request.Method}"); + Console.WriteLineInterpolated($"{Yellow} Headers:"); + if (RequestDetails.Request.Headers.Count > 0) { + foreach (var header in RequestDetails.Request.Headers) { + if (header.Value is null) { + continue; + } + Console.WriteLineInterpolated($"{property} {header.Key}: {value}{header.Value.Value}"); + } + } + if (RequestDetails.Request.Content.Body.HasValue) { + Console.WriteLineInterpolated($"{Yellow} Content:"); + Console.WriteLineInterpolated($"{property} ContentType: {value}{RequestDetails.Request.Content.GetContentType()}"); + Console.WriteLineInterpolated($"{property} Body: {value}{RequestDetails.Request.Content.Body}"); + } else { + Console.WriteLineInterpolated($"{property} Content: {value}none"); + } + + // Proxy + Console.WriteLineInterpolated($"{headerColor}Proxy:"); + Console.WriteLineInterpolated($"{property} Bypass: {value}{RequestDetails.Proxy.Bypass}"); + Console.WriteLineInterpolated($"{property} Host: {value}{RequestDetails.Proxy.Host}"); + Console.WriteLineInterpolated($"{property} Username: {value}{RequestDetails.Proxy.Username}"); + Console.WriteLineInterpolated($"{property} Password: {value}{RequestDetails.Proxy.Password}"); + Console.WriteLineInterpolated($"{property} Ignore SSL: {value}{RequestDetails.Proxy.IgnoreSSL}"); + } +} \ No newline at end of file From 6864baf0b3a86d93d52be274f19858ea79260d06 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 14:22:32 +0200 Subject: [PATCH 064/105] Added checkForUpdatesModel --- src/Pulse/Core/Commands.cs | 23 ++++++++++--------- src/Pulse/Models/CheckForUpdatesModel.cs | 28 ++++++++++++++++++++++++ src/Pulse/Models/RunConfiguration.cs | 4 ++-- 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 src/Pulse/Models/CheckForUpdatesModel.cs diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 42606ae..a26c515 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -90,7 +90,11 @@ public static async Task Root([Argument] string requestFile, /// /// /// - public static async Task CheckForUpdates(CancellationToken ct = default) { + public static async Task CheckForUpdates(ConsoleAppContext context, CancellationToken ct = default) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + using var client = new HttpClient(); client.DefaultRequestHeaders.Add("User-Agent", "C# App"); client.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json"); @@ -104,15 +108,14 @@ public static async Task CheckForUpdates(CancellationToken ct = default) { } ArgumentNullException.ThrowIfNull(remoteVersion); var currentVersion = Version.Parse(VERSION); - if (currentVersion < remoteVersion) { - Console.WriteLineInterpolated($"{Yellow}A new version of Pulse is available!"); - Console.WriteLineInterpolated($"Your version: {Markup.Underline}{Yellow}{VERSION}{Markup.ResetUnderline}"); - Console.WriteLineInterpolated($"Latest version: {Markup.Underline}{Green}{remoteVersion}{Markup.ResetUnderline}"); - Console.NewLine(); - Console.WriteLineInterpolated($"Download from {Markup.Underline}https://github.com/dusrdev/Pulse/releases/latest{Markup.ResetUnderline}"); - } else { - Console.WriteLineInterpolated($"{Green}You are using the latest version of Pulse."); - } + + var outputModel = new CheckForUpdatesModel { + CurrentVersion = currentVersion, + RemoteVersion = remoteVersion, + UpdateRequired = currentVersion < remoteVersion + }; + + outputModel.Output(options.Format); return 0; } else { Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to check for updates - server response was not success"); diff --git a/src/Pulse/Models/CheckForUpdatesModel.cs b/src/Pulse/Models/CheckForUpdatesModel.cs new file mode 100644 index 0000000..ded91d0 --- /dev/null +++ b/src/Pulse/Models/CheckForUpdatesModel.cs @@ -0,0 +1,28 @@ +namespace Pulse.Models; + +internal readonly struct CheckForUpdatesModel : IOutputFormatter { + public required Version CurrentVersion { get; init; } + public required Version RemoteVersion { get; init; } + public required bool UpdateRequired { get; init; } + public string RemoteRepository { get; init; } = RemoteRepositoryUrl; + private const string RemoteRepositoryUrl = "https://github.com/dusrdev/Pulse/releases/latest"; + + public CheckForUpdatesModel() { + } + + public void OutputAsJson() { + throw new NotImplementedException(); + } + + public void OutputAsPlainText() { + if (UpdateRequired) { + Console.WriteLineInterpolated($"{Yellow}A new version of Pulse is available!"); + Console.WriteLineInterpolated($"Your version: {Markup.Underline}{Yellow}{CurrentVersion}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"Latest version: {Markup.Underline}{Green}{RemoteVersion}{Markup.ResetUnderline}"); + Console.NewLine(); + Console.WriteLineInterpolated($"Download from {Markup.Underline}{RemoteRepository}{Markup.ResetUnderline}"); + } else { + Console.WriteLineInterpolated($"{Green}You are using the latest version of Pulse."); + } + } +} \ No newline at end of file diff --git a/src/Pulse/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs index 3662dde..5619c86 100644 --- a/src/Pulse/Models/RunConfiguration.cs +++ b/src/Pulse/Models/RunConfiguration.cs @@ -3,8 +3,8 @@ namespace Pulse.Models; internal readonly struct RunConfiguration : IOutputFormatter { - public Parameters Parameters { get; init; } - public RequestDetails RequestDetails { get; init; } + public required Parameters Parameters { get; init; } + public required RequestDetails RequestDetails { get; init; } public void OutputAsJson() { JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.RunConfiguration); From 6d4d3b71a85f0f1adc39df4dd8d16978099bbc3a Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 14:42:44 +0200 Subject: [PATCH 065/105] General cleanup --- src/Pulse/Configuration/DefaultJsonContext.cs | 3 +- src/Pulse/Configuration/StrippedException.cs | 1 - src/Pulse/Core/Commands.cs | 22 +++-- src/Pulse/Core/ConsoleState.cs | 10 +-- src/Pulse/Core/GlobalExceptionHandler.cs | 4 +- src/Pulse/Core/Helper.cs | 18 ++-- src/Pulse/Core/IPulseMonitor.cs | 3 +- src/Pulse/Core/Pulse.cs | 1 - src/Pulse/Core/PulseHttpClientFactory.cs | 2 +- src/Pulse/Core/PulseMonitor.cs | 21 +++-- src/Pulse/Core/PulseSummary.cs | 84 ++++++++++--------- src/Pulse/Core/VerbosePulseMonitor.cs | 1 - src/Pulse/Models/CheckForUpdatesModel.cs | 4 +- src/Pulse/Models/GetSampleModel.cs | 15 ++++ src/Pulse/Models/GlobalOptions.cs | 2 +- src/Pulse/Models/IOutputFormatter.cs | 4 +- src/Pulse/Models/ModelsJsonContext.cs | 2 + src/Pulse/Models/Target.cs | 4 +- src/Pulse/Program.cs | 2 +- tests/Pulse.Tests.Unit/ExporterTests.cs | 1 + tests/Pulse.Tests.Unit/HelperTests.cs | 4 +- .../HttpClientFactoryTests.cs | 1 + tests/Pulse.Tests.Unit/ParametersTests.cs | 1 + tests/Pulse.Tests.Unit/PulseMonitorTests.cs | 1 + .../Pulse.Tests.Unit/ResponseComparerTests.cs | 2 +- tests/Pulse.Tests.Unit/SummaryTests.cs | 3 +- tests/Pulse.Tests.Unit/VersionTests.cs | 2 +- 27 files changed, 127 insertions(+), 91 deletions(-) create mode 100644 src/Pulse/Models/GetSampleModel.cs diff --git a/src/Pulse/Configuration/DefaultJsonContext.cs b/src/Pulse/Configuration/DefaultJsonContext.cs index 4a3b501..d89b71c 100644 --- a/src/Pulse/Configuration/DefaultJsonContext.cs +++ b/src/Pulse/Configuration/DefaultJsonContext.cs @@ -1,13 +1,12 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Pulse.Core; using Pulse.Models; namespace Pulse.Configuration; [JsonSourceGenerationOptions(AllowTrailingCommas = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, UnknownTypeHandling = JsonUnknownTypeHandling.JsonElement, PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Configuration/StrippedException.cs index 220667f..903c1bc 100644 --- a/src/Pulse/Configuration/StrippedException.cs +++ b/src/Pulse/Configuration/StrippedException.cs @@ -67,7 +67,6 @@ private StrippedException(Exception exception) { /// /// /// - /// public StrippedException(string type, string message) { Type = type; Message = message; diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index a26c515..36cedd5 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -13,7 +13,7 @@ namespace Pulse.Core; /// Commands /// internal static class Commands { - public const string VERSION = "2.0.0.0"; + public const string Version = "2.0.0.0"; /// /// Pulse - A hyper fast general purpose HTTP request tester @@ -88,6 +88,7 @@ public static async Task Root([Argument] string requestFile, /// /// Checks whether there is a new version out on GitHub releases. /// + /// /// /// public static async Task CheckForUpdates(ConsoleAppContext context, CancellationToken ct = default) { @@ -107,7 +108,7 @@ public static async Task CheckForUpdates(ConsoleAppContext context, Cancell return 1; } ArgumentNullException.ThrowIfNull(remoteVersion); - var currentVersion = Version.Parse(VERSION); + var currentVersion = System.Version.Parse(Version); var outputModel = new CheckForUpdatesModel { CurrentVersion = currentVersion, @@ -117,10 +118,10 @@ public static async Task CheckForUpdates(ConsoleAppContext context, Cancell outputModel.Output(options.Format); return 0; - } else { - Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to check for updates - server response was not success"); - return 1; } + + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to check for updates - server response was not success"); + return 1; } /// @@ -157,15 +158,22 @@ public static async Task GetSchema(string? directory = null, CancellationTo /// /// Generate sample request file. /// + /// /// -d, Configures in which directory [will default to current] /// /// - public static async Task GetSample(string? directory = null, CancellationToken ct = default) { + public static async Task GetSample(ConsoleAppContext context, string? directory = null, CancellationToken ct = default) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } directory ??= Directory.GetCurrentDirectory(); var path = Path.Join(directory, "sample.json"); var json = JsonSerializer.Serialize(new RequestDetails(), InputJsonContext.Default.RequestDetails); await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false); - Console.WriteLineInterpolated($"Sample request generated at {Markup.Underline}{Yellow}{path}{Markup.ResetUnderline}"); + var output = new GetSampleModel { + Path = path + }; + output.Output(options.Format); return 0; } diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index c92a9cb..e3b036c 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -1,12 +1,12 @@ namespace Pulse.Core; internal static class ConsoleState { - public static int LinesWritten { - get => field; - set => Interlocked.Exchange(ref field, value); - } + public static int LinesWritten { + get; + set => Interlocked.Exchange(ref field, value); + } - public static void Reset(int startLine) { + public static void Reset(int startLine) { LinesWritten = startLine; } diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index 268967f..e69275b 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -25,7 +25,7 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}and provide the following details:"); Console.NewLine(OutputPipe.Error); - Helper.PrintException(StrippedException.FromException(e)); + StrippedException.FromException(e).PrintException(); Environment.ExitCode = 1; } @@ -33,7 +33,7 @@ static void ClearFrom(int start) { int last = Math.Max(Console.GetCurrentLine(), ConsoleState.LinesWritten); int lines = Math.Max(1, last - start + 1); Console.GoToLine(start); - Console.ClearNextLines(lines, OutputPipe.Error); + Console.ClearNextLines(lines); Console.GoToLine(start); Console.ClearNextLines(lines, OutputPipe.Out); Console.GoToLine(start); diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index 84bc377..c99b640 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -14,11 +14,19 @@ public static double Percentage(T current, T total) where T : INumberBase return double.CreateChecked(current / total); } - public static TimeSpan GetETA(double percentage, TimeSpan elapsed) { - if (percentage <= 0) return TimeSpan.MaxValue; - if (percentage >= 1) return TimeSpan.Zero; - var rem = (1 - percentage) / percentage; - return rem * elapsed; + public static TimeSpan GetEta(double percentage, TimeSpan elapsed) { + switch (percentage) + { + case <= 0: + return TimeSpan.MaxValue; + case >= 1: + return TimeSpan.Zero; + default: + { + var rem = (1 - percentage) / percentage; + return rem * elapsed; + } + } } // Returns an OutputFormat based on the llm parameter diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index b8ca94e..67647b9 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -59,7 +59,6 @@ public async Task SendRequest(int id, Request requestRecipe, HttpClien var headers = Enumerable.Empty>>(); using var message = requestRecipe.CreateMessage(); long start = Stopwatch.GetTimestamp(); - TimeSpan elapsed = TimeSpan.Zero; HttpResponseMessage? response = null; try { currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); @@ -71,7 +70,7 @@ public async Task SendRequest(int id, Request requestRecipe, HttpClien } finally { Interlocked.Decrement(ref _currentConcurrentConnections.Value); } - elapsed = Stopwatch.GetElapsedTime(start); + TimeSpan elapsed = Stopwatch.GetElapsedTime(start); if (!exception.IsDefault) { return new Response { Id = id, diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 78cb50b..e292a47 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -1,4 +1,3 @@ -using Pulse.Configuration; using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index 860e8da..ee84311 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -45,7 +45,7 @@ internal static SocketsHttpHandler CreateHandler(Proxy proxyDetails) { Password = proxyDetails.Password }; } - handler = new SocketsHttpHandler() { + handler = new SocketsHttpHandler { UseProxy = true, Proxy = proxy }; diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 064fb88..685a707 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Threading.Channels; -using Pulse.Configuration; using Pulse.Models; using static Pulse.Core.IPulseMonitor; @@ -68,12 +67,12 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet Percentage = 0, CurrentCount = _responses, SuccessRate = 0, - ETA = TimeSpan.MaxValue, + Eta = TimeSpan.MaxValue, RequestCount = _requestCount, StatusCodes = _stats }); - System.Console.CursorVisible = false; + Console.CursorVisible = false; ConsoleState.ReportLinesFromCurrent(3); _printer = Task.Run(async () => { @@ -102,8 +101,8 @@ public async Task SendAsync(int requestId) { /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private async ValueTask PushMetricsAsync() { - var percentage = (double)_responses.Value / _requestCount; - var eta = Helper.GetETA(percentage, Stopwatch.GetElapsedTime(_start)); + var percentage = Helper.Percentage(_responses.Value, _requestCount); + var eta = Helper.GetEta(percentage, Stopwatch.GetElapsedTime(_start)); double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); var stats = new Stats { @@ -111,7 +110,7 @@ private async ValueTask PushMetricsAsync() { CurrentCount = _responses, RequestCount = _requestCount, StatusCodes = _stats, - ETA = eta, + Eta = eta, SuccessRate = sr }; @@ -124,16 +123,16 @@ private static void PrintMetrics(Stats stats) { Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green, maxLineWidth: 34); Console.NewLine(OutputPipe.Error); - Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.ETA:duration}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); - }, 3, OutputPipe.Error); + }, 3); } private readonly struct Stats { public required PaddedULong CurrentCount { get; init; } public required PaddedULong[] StatusCodes { get; init; } public required double Percentage { get; init; } - public required TimeSpan ETA { get; init; } + public required TimeSpan Eta { get; init; } public required double SuccessRate { get; init; } public required ulong RequestCount { get; init; } } @@ -143,10 +142,10 @@ public async Task ClearAndReturnAsync() { // Clear after metrics _channel.Writer.Complete(); await _printer.ConfigureAwait(false); - Console.ClearNextLines(3, OutputPipe.Error); + Console.ClearNextLines(3); Console.CursorVisible = true; - return new() { + return new PulseResult { Results = _results, SuccessRate = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2), TotalDuration = Stopwatch.GetElapsedTime(_start) diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index eb9f798..d295721 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -3,7 +3,6 @@ using System.Runtime.InteropServices; using System.Runtime.Intrinsics; -using Pulse.Configuration; using Pulse.Models; namespace Pulse.Core; @@ -38,7 +37,7 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai ConsoleState.ReportLinesFromCurrent(1); Console.Overwrite(() => { Console.WriteInterpolated(OutputPipe.Error, $"Cross referencing results..."); - }, 1, OutputPipe.Error); + }); foreach (var result in pulseResult.Results) { uniqueRequests.Add(result); @@ -67,8 +66,8 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai Summary sizeSummary = GetSummary(CollectionsMarshal.AsSpan(sizes), false); double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; - // Clear "cross referencing results..." - Console.ClearNextLines(1, OutputPipe.Error); + // Clear "cross-referencing results..." + Console.ClearNextLines(1); var output = new SummaryModel { Target = new Target { @@ -152,45 +151,48 @@ internal static async ValueTask SummarizeSingleAsync(Parameters parameters, Requ /// Creates an IQR summary from /// /// + /// /// internal static Summary GetSummary(Span values, bool removeOutliers = true) { - // if conditions ordered to promote default paths + switch (values.Length) + { + case > 2: + { + values.Sort(); + + if (!removeOutliers) { + return SummarizeOrderedSpan(values, 0); + } - if (values.Length > 2) { - values.Sort(); + int i25 = values.Length / 4, i75 = 3 * values.Length / 4; + double q1 = values[i25]; // First quartile - if (!removeOutliers) { - return SummarizeOrderedSpan(values, 0); - } + double q3 = values[i75]; // Third quartile - int i25 = values.Length / 4, i75 = 3 * values.Length / 4; - double q1 = values[i25]; // First quartile - - double q3 = values[i75]; // Third quartile - - double iqr = q3 - q1; - double lowerBound = q1 - 1.5 * iqr; - double upperBound = q3 + 1.5 * iqr; - - int start = FindBoundIndex(values, lowerBound, 0, i25); - int end = FindBoundIndex(values, upperBound, i75, values.Length); - ReadOnlySpan filtered = values.Slice(start, end - start); - - return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); - } else if (values.Length is 2) { - return new Summary { - Min = Math.Min(values[0], values[1]), - Max = Math.Max(values[0], values[1]), - Mean = (values[0] + values[1]) / 2 - }; - } else if (values.Length is 1) { - return new Summary { - Min = values[0], - Max = values[0], - Mean = values[0] - }; - } else { - return new(); + double iqr = q3 - q1; + double lowerBound = q1 - 1.5 * iqr; + double upperBound = q3 + 1.5 * iqr; + + int start = FindBoundIndex(values, lowerBound, 0, i25); + int end = FindBoundIndex(values, upperBound, i75, values.Length); + ReadOnlySpan filtered = values.Slice(start, end - start); + + return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); + } + case 2: + return new Summary { + Min = Math.Min(values[0], values[1]), + Max = Math.Max(values[0], values[1]), + Mean = (values[0] + values[1]) / 2 + }; + case 1: + return new Summary { + Min = values[0], + Max = values[0], + Mean = values[0] + }; + default: + return new Summary(); } } @@ -207,7 +209,7 @@ internal static Summary SummarizeOrderedSpan(ReadOnlySpan values, int re return new Summary { Min = values[0], Max = values[values.Length - 1], - Mean = Mean(values), + Mean = CalculateMean(values), Removed = removed }; } @@ -219,7 +221,7 @@ internal struct Summary { public int Removed; } - internal static double Mean(ReadOnlySpan span) { + internal static double CalculateMean(ReadOnlySpan span) { double mean = 0; double reciprocal = 1.0 / span.Length; int i = 0; @@ -258,8 +260,8 @@ internal static double Mean(ReadOnlySpan span) { /// /// Exports unique request results asynchronously and in parallel if possible /// + /// /// - /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests) { diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 7d3269a..9a2069d 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using System.Net; -using Pulse.Configuration; using Pulse.Models; using static Pulse.Core.IPulseMonitor; diff --git a/src/Pulse/Models/CheckForUpdatesModel.cs b/src/Pulse/Models/CheckForUpdatesModel.cs index ded91d0..b4ee185 100644 --- a/src/Pulse/Models/CheckForUpdatesModel.cs +++ b/src/Pulse/Models/CheckForUpdatesModel.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace Pulse.Models; internal readonly struct CheckForUpdatesModel : IOutputFormatter { @@ -11,7 +13,7 @@ public CheckForUpdatesModel() { } public void OutputAsJson() { - throw new NotImplementedException(); + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.CheckForUpdatesModel); } public void OutputAsPlainText() { diff --git a/src/Pulse/Models/GetSampleModel.cs b/src/Pulse/Models/GetSampleModel.cs new file mode 100644 index 0000000..132d7a0 --- /dev/null +++ b/src/Pulse/Models/GetSampleModel.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace Pulse.Models; + +internal readonly struct GetSampleModel : IOutputFormatter { + public required string Path { get; init; } + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.GetSampleModel); + } + + public void OutputAsPlainText() { + Console.WriteLineInterpolated($"Sample request generated at {Markup.Underline}{Yellow}{Path}{Markup.ResetUnderline}"); + } +} \ No newline at end of file diff --git a/src/Pulse/Models/GlobalOptions.cs b/src/Pulse/Models/GlobalOptions.cs index 037f787..2badd9f 100644 --- a/src/Pulse/Models/GlobalOptions.cs +++ b/src/Pulse/Models/GlobalOptions.cs @@ -3,5 +3,5 @@ namespace Pulse.Models; /// /// Global options that can be used across commands. /// -/// The output format to use +/// The output format to use internal record GlobalOptions(OutputFormat Format); \ No newline at end of file diff --git a/src/Pulse/Models/IOutputFormatter.cs b/src/Pulse/Models/IOutputFormatter.cs index f2dc8d8..3060097 100644 --- a/src/Pulse/Models/IOutputFormatter.cs +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -1,9 +1,9 @@ namespace Pulse.Models; internal interface IOutputFormatter { - abstract void OutputAsPlainText(); + void OutputAsPlainText(); - abstract void OutputAsJson(); + void OutputAsJson(); } internal enum OutputFormat { diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index 473ffc7..2f08411 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -5,6 +5,8 @@ namespace Pulse.Models; +[JsonSerializable(typeof(GetSampleModel))] +[JsonSerializable(typeof(CheckForUpdatesModel))] [JsonSerializable(typeof(RunConfiguration))] [JsonSerializable(typeof(Parameters))] [JsonSerializable(typeof(RequestDetails))] diff --git a/src/Pulse/Models/Target.cs b/src/Pulse/Models/Target.cs index 85950fc..54c1402 100644 --- a/src/Pulse/Models/Target.cs +++ b/src/Pulse/Models/Target.cs @@ -1,6 +1,6 @@ namespace Pulse.Models; internal readonly struct Target { - public readonly string HttpMethod { get; init; } - public readonly string Url { get; init; } + public string HttpMethod { get; init; } + public string Url { get; init; } } \ No newline at end of file diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 48bbca9..d788c6d 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -3,7 +3,7 @@ using Pulse.Core; using Pulse.Models; -ConsoleApp.Version = Commands.VERSION; +ConsoleApp.Version = Commands.Version; var app = ConsoleApp.Create(); diff --git a/tests/Pulse.Tests.Unit/ExporterTests.cs b/tests/Pulse.Tests.Unit/ExporterTests.cs index 9c92427..1f9aa55 100644 --- a/tests/Pulse.Tests.Unit/ExporterTests.cs +++ b/tests/Pulse.Tests.Unit/ExporterTests.cs @@ -4,6 +4,7 @@ using Pulse.Configuration; using Pulse.Core; +using Pulse.Models; namespace Pulse.Tests.Unit; diff --git a/tests/Pulse.Tests.Unit/HelperTests.cs b/tests/Pulse.Tests.Unit/HelperTests.cs index f9ce90d..7b4aeb6 100644 --- a/tests/Pulse.Tests.Unit/HelperTests.cs +++ b/tests/Pulse.Tests.Unit/HelperTests.cs @@ -18,7 +18,7 @@ public void Extensions_GetPercentageBasedColor(double percentage, ConsoleColor e var color = Helper.GetPercentageBasedColor(percentage); // Assert - Assert.Equal(expected, color.ConsoleColor); + Assert.Equal(expected, color); } [Theory] @@ -33,6 +33,6 @@ public void Extensions_GetStatusCodeBasedColor(HttpStatusCode statusCode, Consol var color = Helper.GetStatusCodeBasedColor((int)statusCode); // Assert - Assert.Equal(expected, color.ConsoleColor); + Assert.Equal(expected, color); } } \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs index 45c1aca..2ea8997 100644 --- a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs +++ b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs @@ -3,6 +3,7 @@ using Pulse.Configuration; using Pulse.Core; +using Pulse.Models; namespace Pulse.Tests.Unit; diff --git a/tests/Pulse.Tests.Unit/ParametersTests.cs b/tests/Pulse.Tests.Unit/ParametersTests.cs index 8f9d953..75bcd58 100644 --- a/tests/Pulse.Tests.Unit/ParametersTests.cs +++ b/tests/Pulse.Tests.Unit/ParametersTests.cs @@ -1,4 +1,5 @@ using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Tests.Unit; diff --git a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs b/tests/Pulse.Tests.Unit/PulseMonitorTests.cs index a26724d..19de2e4 100644 --- a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs +++ b/tests/Pulse.Tests.Unit/PulseMonitorTests.cs @@ -1,4 +1,5 @@ using Pulse.Core; +using Pulse.Models; namespace Pulse.Tests.Unit; diff --git a/tests/Pulse.Tests.Unit/ResponseComparerTests.cs b/tests/Pulse.Tests.Unit/ResponseComparerTests.cs index 257d65e..1658b68 100644 --- a/tests/Pulse.Tests.Unit/ResponseComparerTests.cs +++ b/tests/Pulse.Tests.Unit/ResponseComparerTests.cs @@ -1,7 +1,7 @@ using System.Net; using Pulse.Configuration; -using Pulse.Core; +using Pulse.Models; namespace Pulse.Tests.Unit; diff --git a/tests/Pulse.Tests.Unit/SummaryTests.cs b/tests/Pulse.Tests.Unit/SummaryTests.cs index cd00be6..b5be661 100644 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ b/tests/Pulse.Tests.Unit/SummaryTests.cs @@ -3,6 +3,7 @@ using Pulse.Configuration; using Pulse.Core; +using Pulse.Models; namespace Pulse.Tests.Unit; @@ -14,7 +15,7 @@ public void Summary_Mean_ReturnsCorrectValue() { var expected = arr.Average(); // Act - var actual = PulseSummary.Mean(arr); + var actual = PulseSummary.CalculateMean(arr); // Assert Assert.Equal(expected, actual, 0.01); diff --git a/tests/Pulse.Tests.Unit/VersionTests.cs b/tests/Pulse.Tests.Unit/VersionTests.cs index 72d1439..62d006f 100644 --- a/tests/Pulse.Tests.Unit/VersionTests.cs +++ b/tests/Pulse.Tests.Unit/VersionTests.cs @@ -6,7 +6,7 @@ public class VersionTests { [Fact] public void Assembly_Version_Matching() { // Arrange - var constantVersion = Version.Parse(Commands.VERSION); + var constantVersion = Version.Parse(Commands.Version); var assemblyVersion = typeof(Program).Assembly.GetName().Version!; // Assert From 71d47050c45057e5158040d5698ef5dfcd643c78 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 14:46:35 +0200 Subject: [PATCH 066/105] Removed compiler directives --- src/Pulse/Core/PulseMonitor.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 685a707..4adfe11 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -99,7 +99,6 @@ public async Task SendAsync(int requestId) { /// /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] private async ValueTask PushMetricsAsync() { var percentage = Helper.Percentage(_responses.Value, _requestCount); var eta = Helper.GetEta(percentage, Stopwatch.GetElapsedTime(_start)); @@ -117,7 +116,6 @@ private async ValueTask PushMetricsAsync() { await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void PrintMetrics(Stats stats) { Console.Overwrite(stats, static s => { Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); From 60b573c2614d3bcdc31876c3139778ffad4c2ebd Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 15:21:18 +0200 Subject: [PATCH 067/105] Implemented use of --llm in root --- src/Pulse/Core/Commands.cs | 7 ++++++- src/Pulse/Core/PulseSummary.cs | 4 ++-- src/Pulse/Models/SummaryModel.cs | 4 ++-- src/Pulse/Pulse.csproj | 1 + 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 36cedd5..e37c38e 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -18,6 +18,7 @@ internal static class Commands { /// /// Pulse - A hyper fast general purpose HTTP request tester /// + /// /// Path to .json request details file [use "get-sample" if you don't have one] /// Try to format response content as JSON /// Export raw results [without wrapping in custom HTML] @@ -33,7 +34,7 @@ internal static class Commands { /// -t, Timeout in milliseconds /// /// - public static async Task Root([Argument] string requestFile, + public static async Task Root(ConsoleAppContext context, [Argument] string requestFile, bool json, bool raw, bool fullEquality, @@ -47,6 +48,9 @@ public static async Task Root([Argument] string requestFile, [Range(1, int.MaxValue)] int number = 1, int timeout = -1, CancellationToken ct = default) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } connections ??= number; var parametersBase = new ParametersBase { @@ -60,6 +64,7 @@ public static async Task Root([Argument] string requestFile, Export = !noExport, NoOp = noOp, Verbose = verbose, + OutputFormat = options.Format, OutputFolder = output }; diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index d295721..fbeb0ae 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -84,7 +84,7 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai Max = latencySummary.Max, }, LatencyOutliersRemoved = latencySummary.Removed, - ContentSize = new MinMeanMax { + ContentSizeInBytes = new MinMeanMax { Min = sizeSummary.Min, Mean = sizeSummary.Mean, Max = sizeSummary.Max @@ -126,7 +126,7 @@ internal static async ValueTask SummarizeSingleAsync(Parameters parameters, Requ Max = latency, }, LatencyOutliersRemoved = 0, - ContentSize = new MinMeanMax { + ContentSizeInBytes = new MinMeanMax { Min = size, Mean = size, Max = size diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs index 671ab3a..6276279 100644 --- a/src/Pulse/Models/SummaryModel.cs +++ b/src/Pulse/Models/SummaryModel.cs @@ -13,7 +13,7 @@ namespace Pulse.Models; public required double SuccessRate { get; init; } public required MinMeanMax LatencyInMilliseconds { get; init; } public required int LatencyOutliersRemoved { get; init; } - public required MinMeanMax ContentSize { get; init; } + public required MinMeanMax ContentSizeInBytes { get; init; } public required double ThroughputBytesPerSecond { get; init; } public required Dictionary StatusCodeCounts { get; init; } @@ -31,7 +31,7 @@ public void OutputAsPlainText() { if (LatencyOutliersRemoved != 0) { Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); } - Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSize.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSize.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSize.Max:bytes}"); + Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSizeInBytes.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSizeInBytes.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSizeInBytes.Max:bytes}"); Console.WriteLineInterpolated($"Total throughput: {Yellow}{ThroughputBytesPerSecond:bytes}/s"); Console.WriteLineInterpolated($"Status codes:"); foreach (var kvp in StatusCodeCounts.OrderBy(static s => (int)s.Key)) { diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 8b3fcde..e659b4b 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -25,6 +25,7 @@ https://github.com/dusrdev/Pulse git true + CA1515 From 0154a2bf001aa7d007a2abf2f29db52023ee8917 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 15:24:43 +0200 Subject: [PATCH 068/105] Simplify extension --- src/Pulse/Models/IOutputFormatter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pulse/Models/IOutputFormatter.cs b/src/Pulse/Models/IOutputFormatter.cs index 3060097..53ef966 100644 --- a/src/Pulse/Models/IOutputFormatter.cs +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -12,7 +12,7 @@ internal enum OutputFormat { } internal static class OutputFormatterExtensions { - internal static void Output(this T value, OutputFormat format) where T : IOutputFormatter, allows ref struct { + internal static void Output(this IOutputFormatter value, OutputFormat format) { switch (format) { case OutputFormat.PlainText: value.OutputAsPlainText(); From 4ebd422451af1310028a657ac4442220d5b72a36 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 15:43:50 +0200 Subject: [PATCH 069/105] Update summary tests to fit new APIs --- tests/Pulse.Tests.Unit/SummaryTests.cs | 36 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/Pulse.Tests.Unit/SummaryTests.cs b/tests/Pulse.Tests.Unit/SummaryTests.cs index b5be661..e6f128e 100644 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ b/tests/Pulse.Tests.Unit/SummaryTests.cs @@ -58,9 +58,20 @@ public SummaryTestData() { } [Fact] - public void Summarize_DeduplicatesResponses_WhenExportEnabled() { + public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { // Arrange - var parameters = new Parameters(new ParametersBase { Export = true }, CancellationToken.None); + var outputFolderName = $"pulse-summary-tests-{Guid.NewGuid():N}"; + var parameters = new Parameters(new ParametersBase { + Export = true, + OutputFolder = outputFolderName + }, TestContext.Current.CancellationToken); + var exportDirectory = Path.Join(Directory.GetCurrentDirectory(), outputFolderName); + var requestDetails = new RequestDetails { + Request = new Request { + Url = "https://example.com", + Method = HttpMethod.Get + } + }; var responses = new[] { CreateResponse(1, HttpStatusCode.OK, "alpha"), CreateResponse(2, HttpStatusCode.OK, "beta"), @@ -73,12 +84,21 @@ public void Summarize_DeduplicatesResponses_WhenExportEnabled() { SuccessRate = 100 }; - // Act - var (exportRequired, uniqueRequests) = PulseSummary.Summarize(parameters, pulseResult, requestSizeInBytes: 16); + try { + // Act + await PulseSummary.SummarizeAsync(parameters, requestDetails, pulseResult); - // Assert - Assert.True(exportRequired); - Assert.Equal(2, uniqueRequests.Count); + // Assert + var exportedFiles = Directory.Exists(exportDirectory) + ? Directory.GetFiles(exportDirectory) + : Array.Empty(); + + Assert.Equal(2, exportedFiles.Length); + } finally { + if (Directory.Exists(exportDirectory)) { + Directory.Delete(exportDirectory, recursive: true); + } + } } private static Response CreateResponse(int id, HttpStatusCode statusCode, string content) { @@ -93,4 +113,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} \ No newline at end of file +} From 90387841e741a991416adf637d8889772314149c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 15:55:12 +0200 Subject: [PATCH 070/105] Update agents.md --- Agents.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Agents.md b/Agents.md index 5a82d1e..b59d376 100644 --- a/Agents.md +++ b/Agents.md @@ -10,9 +10,16 @@ ### Command & Orchestration Agent - **Files:** `src/Pulse/Program.cs`, `src/Pulse/Core/Commands.cs` - Registers the command surface (`Pulse`, `get-sample`, `get-schema`, `update`, `terms-of-use`) and wires a global exception filter. +- Adds a global `--llm` flag that toggles structured JSON output for every command by populating `GlobalOptions` with an `OutputFormat` value. - `Commands.Root` parses CLI inputs into `ParametersBase`, loads request definitions from disk via `InputJsonContext`, and triggers the execution pipeline. - Hosts helper commands that generate request samples and JSON Schema artifacts, print terms of use, and query GitHub releases for updates. +### Output Formatting Agent +- **Files:** `src/Pulse/Models/IOutputFormatter.cs`, `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Core/Helper.cs`, `src/Pulse/Models/*Model.cs` +- `IOutputFormatter` defines paired `OutputAsPlainText` / `OutputAsJson` methods with an `Output(OutputFormat)` extension to centralize human-vs-LLM rendering. +- `OutputFormat` (PlainText/JSON) is derived from the global `--llm` switch via `Helper.OutputFormatFromBool` and stored in `GlobalOptions` for all commands. +- User-facing models (`RunConfiguration`, `SummaryModel`, `TermsOfServiceModel`, `CheckForUpdatesModel`, `GetSampleModel`) implement the interface so the same execution flow can emit colored console text or serialized JSON. + ### Configuration Agent - **Files:** `src/Pulse/Configuration/InputJsonContext.cs`, `src/Pulse/Configuration/DefaultJsonContext.cs`, `src/Pulse/Configuration/Parameters.cs`, `src/Pulse/Core/RequestDetails.cs` - Source generators (`JsonSerializerContext`) provide strongly typed serializers for request payloads, headers, exceptions, and release metadata. @@ -67,11 +74,11 @@ - `VersionTests.cs` keep `Commands.VERSION` synchronized with assembly metadata. ## Data Flow Snapshot -1. User invokes the CLI; `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. +1. User invokes the CLI; the global `--llm` flag sets `GlobalOptions.OutputFormat`, and `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. 2. `Pulse.RunAsync` creates proxy-aware HTTP plumbing, chooses a monitor (verbose or dashboard), and schedules the requested workload with semaphore-throttled concurrency. 3. `RequestExecutionContext` issues HTTP requests, captures responses or exceptions, and records latency and concurrency metrics inside `Response`. 4. Monitors update live console feedback and accumulate results before handing off a `PulseResult`. -5. `PulseSummary` prints aggregated metrics, deduplicates responses, and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder. +5. `PulseSummary` materializes `SummaryModel` and other `IOutputFormatter` models, printing either plaintext or JSON, then deduplicates responses and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder. 6. `GlobalExceptionHandler` guarantees graceful shutdown, while optional commands (`get-sample`, `get-schema`, `update`, `terms-of-use`) reuse serialization agents for auxiliary workflows. ## External Dependencies From 4a04dad6286cbfb143964497525002b588394c8c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 15:59:36 +0200 Subject: [PATCH 071/105] renamed llm flag to output-format --- src/Pulse/Core/Helper.cs | 4 ---- src/Pulse/Program.cs | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index c99b640..b3e5b9b 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -29,10 +29,6 @@ public static TimeSpan GetEta(double percentage, TimeSpan elapsed) { } } - // Returns an OutputFormat based on the llm parameter - public static OutputFormat OutputFormatFromBool(bool llm = false) - => llm ? OutputFormat.JSON : OutputFormat.PlainText; - /// /// Returns a text color based on percentage /// diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index d788c6d..4e1e8f9 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -10,8 +10,8 @@ app.UseFilter(); app.ConfigureGlobalOptions((ref builder) => { - var llm = builder.AddGlobalOption("--llm", description: "Output using structured JSON", defaultValue: false); - return new GlobalOptions(Helper.OutputFormatFromBool(llm)); + var format = builder.AddGlobalOption("--output-format", description: "Output as PlainText|JSON", defaultValue: OutputFormat.PlainText); + return new GlobalOptions(format); }); app.Add("", Commands.Root); From 39792812ebfc31b98a4c5a9eae105dccdeba6ba0 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:09:29 +0200 Subject: [PATCH 072/105] End json with newline --- src/Pulse/Models/ModelsJsonContext.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index 2f08411..9047bca 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -22,6 +22,7 @@ internal static void ToConsoleOut(in T value, JsonTypeInfo jsonTypeInfo) { using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); JsonSerializer.Serialize(writer, value, jsonTypeInfo); writer.Flush(); + Console.WriteLine(); } } } \ No newline at end of file From f35da75ef8cfae2649bb7be692ec916f9b87ccee Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:10:50 +0200 Subject: [PATCH 073/105] update dependencies --- profiling/runner.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profiling/runner.cs b/profiling/runner.cs index e568404..051313d 100644 --- a/profiling/runner.cs +++ b/profiling/runner.cs @@ -1,6 +1,6 @@ #:package PrettyConsole@5.0.0 -#:package ConsoleAppFramework@5.7.9 -#:package CliWrap@3.9.0 +#:package ConsoleAppFramework@5.7.11 +#:package CliWrap@3.10.0 using CliWrap; using CliWrap.Buffered; From 3c05498e4866d302d35ad0ce3ea116c1045ffad2 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:37:09 +0200 Subject: [PATCH 074/105] Added "quiet" flag. --- src/Pulse/Core/Commands.cs | 1 + src/Pulse/Core/PulseMonitor.cs | 70 ++++++++++++++++----------- src/Pulse/Core/PulseSummary.cs | 17 ++++--- src/Pulse/Core/VerbosePulseMonitor.cs | 20 +++++--- src/Pulse/Models/GlobalOptions.cs | 3 +- src/Pulse/Models/Parameters.cs | 5 ++ src/Pulse/Models/RunConfiguration.cs | 1 + src/Pulse/Program.cs | 3 +- 8 files changed, 77 insertions(+), 43 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index e37c38e..7ebaa4b 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -65,6 +65,7 @@ public static async Task Root(ConsoleAppContext context, [Argument] string NoOp = noOp, Verbose = verbose, OutputFormat = options.Format, + Quiet = options.Quiet, OutputFolder = output }; diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 4adfe11..8b60cd4 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -43,12 +43,9 @@ internal sealed class PulseMonitor : IPulseMonitor { private readonly HttpClient _httpClient; private readonly Request _requestRecipe; private readonly Task _printer; + private readonly bool _reportProgress; - private readonly Channel _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { - SingleWriter = false, - SingleReader = true, - FullMode = BoundedChannelFullMode.DropWrite - }); + private readonly Channel _channel; /// /// Creates a new pulse monitor @@ -61,25 +58,38 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet _httpClient = client; _requestRecipe = requestRecipe; _requestExecutionContext = new RequestExecutionContext(); + _reportProgress = !parameters.Quiet; _start = Stopwatch.GetTimestamp(); - _ = _channel.Writer.TryWrite(new Stats { - Percentage = 0, - CurrentCount = _responses, - SuccessRate = 0, - Eta = TimeSpan.MaxValue, - RequestCount = _requestCount, - StatusCodes = _stats - }); - - Console.CursorVisible = false; - ConsoleState.ReportLinesFromCurrent(3); - - _printer = Task.Run(async () => { - await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { - PrintMetrics(stats); - } - }); + if (_reportProgress) { + _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { + SingleWriter = false, + SingleReader = true, + FullMode = BoundedChannelFullMode.DropWrite + }); + + _ = _channel.Writer.TryWrite(new Stats { + Percentage = 0, + CurrentCount = _responses, + SuccessRate = 0, + Eta = TimeSpan.MaxValue, + RequestCount = _requestCount, + StatusCodes = _stats + }); + + Console.CursorVisible = false; + ConsoleState.ReportLinesFromCurrent(3); + + _printer = Task.Run(async () => { + await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { + PrintMetrics(stats); + } + }); + } else { + _channel = null!; + + _printer = Task.CompletedTask; + } } /// @@ -92,7 +102,9 @@ public async Task SendAsync(int requestId) { Interlocked.Increment(ref _stats[index].Value); // Print metrics - await PushMetricsAsync().ConfigureAwait(false); + if (_reportProgress) { + await PushMetricsAsync().ConfigureAwait(false); + } _results.Push(result); } @@ -137,11 +149,13 @@ private readonly struct Stats { /// public async Task ClearAndReturnAsync() { - // Clear after metrics - _channel.Writer.Complete(); - await _printer.ConfigureAwait(false); - Console.ClearNextLines(3); - Console.CursorVisible = true; + if (_reportProgress) { + // Clear after metrics + _channel.Writer.Complete(); + await _printer.ConfigureAwait(false); + Console.ClearNextLines(3); + Console.CursorVisible = true; + } return new PulseResult { Results = _results, diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index fbeb0ae..dce38ea 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -34,10 +34,12 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai long totalSize = 0; int peakConcurrentConnections = 0; - ConsoleState.ReportLinesFromCurrent(1); - Console.Overwrite(() => { - Console.WriteInterpolated(OutputPipe.Error, $"Cross referencing results..."); - }); + if (!parameters.Quiet) { + ConsoleState.ReportLinesFromCurrent(1); + Console.Overwrite(() => { + Console.WriteInterpolated(OutputPipe.Error, $"Cross referencing results..."); + }); + } foreach (var result in pulseResult.Results) { uniqueRequests.Add(result); @@ -66,8 +68,10 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai Summary sizeSummary = GetSummary(CollectionsMarshal.AsSpan(sizes), false); double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; - // Clear "cross-referencing results..." - Console.ClearNextLines(1); + if (!parameters.Quiet) { + // Clear "cross-referencing results..." + Console.ClearNextLines(1); + } var output = new SummaryModel { Target = new Target { @@ -263,7 +267,6 @@ internal static double CalculateMean(ReadOnlySpan span) { /// /// /// - [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests) { var count = uniqueRequests.Count; diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs index 9a2069d..2c35365 100644 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ b/src/Pulse/Core/VerbosePulseMonitor.cs @@ -37,6 +37,7 @@ internal sealed class VerbosePulseMonitor : IPulseMonitor { private readonly HttpClient _httpClient; private readonly Request _requestRecipe; private readonly RequestExecutionContext _requestExecutionContext; + private readonly bool _reportProgress; private readonly Lock _lock = new(); @@ -50,13 +51,16 @@ public VerbosePulseMonitor(HttpClient client, Request requestRecipe, Parameters _httpClient = client; _requestRecipe = requestRecipe; _requestExecutionContext = new RequestExecutionContext(); + _reportProgress = !parameters.Quiet; _start = Stopwatch.GetTimestamp(); } /// public async Task SendAsync(int requestId) { - lock (_lock) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}--> {ConsoleColor.Default}Sent request: {Yellow}{requestId}"); + if (_reportProgress) { + lock (_lock) { + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}--> {ConsoleColor.Default}Sent request: {Yellow}{requestId}"); + } } var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); Interlocked.Increment(ref _responses.Value); @@ -64,16 +68,20 @@ public async Task SendAsync(int requestId) { if (result.StatusCode is HttpStatusCode.OK) { Interlocked.Increment(ref _successes.Value); } - int status = (int)result.StatusCode; - lock (_lock) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}<-- {ConsoleColor.Default}Received response: {Yellow}{requestId}{ConsoleColor.Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); + if (_reportProgress) { + int status = (int)result.StatusCode; + lock (_lock) { + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}<-- {ConsoleColor.Default}Received response: {Yellow}{requestId}{ConsoleColor.Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); + } } _results.Push(result); } /// public Task ClearAndReturnAsync() { - Console.NewLine(OutputPipe.Error); + if (_reportProgress) { + Console.NewLine(OutputPipe.Error); + } return Task.FromResult(new PulseResult { Results = _results, diff --git a/src/Pulse/Models/GlobalOptions.cs b/src/Pulse/Models/GlobalOptions.cs index 2badd9f..9655cbf 100644 --- a/src/Pulse/Models/GlobalOptions.cs +++ b/src/Pulse/Models/GlobalOptions.cs @@ -4,4 +4,5 @@ namespace Pulse.Models; /// Global options that can be used across commands. /// /// The output format to use -internal record GlobalOptions(OutputFormat Format); \ No newline at end of file +/// Suppress progress output on stderr (only fatal errors will be shown) +internal record GlobalOptions(OutputFormat Format, bool Quiet); \ No newline at end of file diff --git a/src/Pulse/Models/Parameters.cs b/src/Pulse/Models/Parameters.cs index e4ea580..365f4f6 100644 --- a/src/Pulse/Models/Parameters.cs +++ b/src/Pulse/Models/Parameters.cs @@ -59,6 +59,11 @@ internal record ParametersBase { /// public OutputFormat OutputFormat { get; init; } + /// + /// Suppress progress output on stderr (only fatal errors will be shown) + /// + public bool Quiet { get; init; } + /// /// Output folder. /// diff --git a/src/Pulse/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs index 5619c86..0eb53cd 100644 --- a/src/Pulse/Models/RunConfiguration.cs +++ b/src/Pulse/Models/RunConfiguration.cs @@ -27,6 +27,7 @@ public void OutputAsPlainText() { Console.WriteLineInterpolated($"{property} Export: {value}{Parameters.Export}"); Console.WriteLineInterpolated($"{property} Verbose: {value}{Parameters.Verbose}"); Console.WriteLineInterpolated($"{property} OutputFormat: {value}{Parameters.OutputFormat}"); + Console.WriteLineInterpolated($"{property} Quiet: {value}{Parameters.Quiet}"); Console.WriteLineInterpolated($"{property} Output Folder: {value}{Parameters.OutputFolder}"); // Request diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 4e1e8f9..5b3a9ed 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -11,7 +11,8 @@ app.ConfigureGlobalOptions((ref builder) => { var format = builder.AddGlobalOption("--output-format", description: "Output as PlainText|JSON", defaultValue: OutputFormat.PlainText); - return new GlobalOptions(format); + var quiet = builder.AddGlobalOption("--quiet", description: "Suppress progress output on stderr (only fatal errors will be shown).", defaultValue: false); + return new GlobalOptions(format, quiet); }); app.Add("", Commands.Root); From f7c85d1253957be346ec6d2adba36f22be4a20d6 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:37:23 +0200 Subject: [PATCH 075/105] Moved model + add formatting --- .../StrippedException.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) rename src/Pulse/{Configuration => Models}/StrippedException.cs (69%) diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Models/StrippedException.cs similarity index 69% rename from src/Pulse/Configuration/StrippedException.cs rename to src/Pulse/Models/StrippedException.cs index 903c1bc..8b642a4 100644 --- a/src/Pulse/Configuration/StrippedException.cs +++ b/src/Pulse/Models/StrippedException.cs @@ -1,13 +1,15 @@ +using System.Text.Json; using System.Text.Json.Serialization; +using Pulse.Configuration; using Pulse.Core; -namespace Pulse.Configuration; +namespace Pulse.Models; /// /// An exception only containing the type, message and stack trace /// -internal sealed record StrippedException { +internal sealed record StrippedException : IOutputFormatter { public static readonly StrippedException Default = new(); /// @@ -48,6 +50,21 @@ public static StrippedException FromException(Exception? exception) { return new StrippedException(exception); } + public void OutputAsPlainText() { + if (Type == nameof(OperationCanceledException)) { + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); + } else { + Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}and provide the following details:"); + Console.NewLine(OutputPipe.Error); + this.PrintException(); + } + } + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(this, DefaultJsonContext.Default.StrippedException); + } + /// /// Creates a stripped exception from an exception /// @@ -63,7 +80,7 @@ private StrippedException(Exception exception) { } /// - /// Creates a stripped exception from a type, message and stack trace + /// Creates a stripped exception from a type and message /// /// /// From 55f89b5a952e59df8068ef4ffb750baa234f07c0 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:37:41 +0200 Subject: [PATCH 076/105] Respect output format --- src/Pulse/Core/GlobalExceptionHandler.cs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index e69275b..2eff467 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -2,7 +2,7 @@ using ConsoleAppFramework; -using Pulse.Configuration; +using Pulse.Models; namespace Pulse.Core; @@ -10,22 +10,28 @@ namespace Pulse.Core; internal sealed class GlobalExceptionHandler(ConsoleAppFilter next) : ConsoleAppFilter(next) { public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + bool reportsProgress = !options.Quiet; + int startLine = Console.GetCurrentLine(); ConsoleState.Reset(startLine); try { await Next.InvokeAsync(context, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (e is ValidationException or ArgumentParseFailedException) { throw; - } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { - ClearFrom(startLine); - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); + } catch (Exception e) when (e is TaskCanceledException or OperationCanceledException) { + if (reportsProgress) { + ClearFrom(startLine); + } + new StrippedException(nameof(OperationCanceledException), "").Output(options.Format); Environment.ExitCode = 1; } catch (Exception e) { - ClearFrom(startLine); - Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); - Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}and provide the following details:"); - Console.NewLine(OutputPipe.Error); - StrippedException.FromException(e).PrintException(); + if (reportsProgress) { + ClearFrom(startLine); + } + StrippedException.FromException(e).Output(options.Format); Environment.ExitCode = 1; } From 3938db76d1acf2d65a896a42ae8eb44efd62979e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:47:50 +0200 Subject: [PATCH 077/105] Update history and removed redundant changelog --- Changelog.md | 18 ------------------ History.md | 5 ++++- 2 files changed, 4 insertions(+), 19 deletions(-) delete mode 100644 Changelog.md diff --git a/Changelog.md b/Changelog.md deleted file mode 100644 index 531fb86..0000000 --- a/Changelog.md +++ /dev/null @@ -1,18 +0,0 @@ -# Changelog - -- Updated `PrettyConsole` to latest version to use higher perf APIs. -- Moved from using `Sharpify.CommandLineInterface` to `ConsoleAppFramework` for better perf and less verbose code. -- Dropped `Sharpify` dependency. -- Many different internals were optimized to provide higher stability and performance. -- `ExecutionMode` is no longer used, and the options were unified: - - To use `Sequential` mode, simply set `-c| --connections` to 1. - - By default `Parallel` number will be used and `connections` will be set to the number of requests. - - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. - - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. -- Many outputs are now more consistent and artifact free, including when the output is interrupted (like when press CTRL+C). -- Added `cli-schema` command prints the usage schema for the app in JSON format - Useful for LLM's and AGENTS. -- Compilations options were refined to produce a even more purpose fit executable. - - Smaller output binary size. - - Shorter startup times. - - Much less memory allocations and lower GC pressure. - - De-abstraction of hot-paths should now results in overall performance increase. diff --git a/History.md b/History.md index 337c1ca..28a52a9 100644 --- a/History.md +++ b/History.md @@ -12,7 +12,10 @@ - To use `Limited` mode, simply set `-c| --connections` to the desired value by use the optional parameter. - `-d| --delay` can now be combined with any of the options above, even though at full `Parallel` it will only delay the results summary. - Many outputs are now more consistent and artifact free, including when the output is interrupted (like when press CTRL+C). -- Added `cli-schema` command prints the usage schema for the app in JSON format - Useful for LLM's and AGENTS. +- First-class LLM and AGENTS support: + - Added `cli-schema` command prints the usage schema for the app in JSON format. + - Added `--output-format` parameter that can be used to change the output from the default plain-text to structured JSON. + - Added `--quiet` parameter that can be used to suppress writing progress to stderr (which can glitch if agents merge the streams). - Compilations options were refined to produce a even more purpose fit executable. - Smaller output binary size. - Shorter startup times. From cd967bc1ecea04f11e9ffb874cffeb83344e4e54 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:51:16 +0200 Subject: [PATCH 078/105] Update readme --- Readme.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Readme.md b/Readme.md index 728c94a..7496534 100644 --- a/Readme.md +++ b/Readme.md @@ -11,8 +11,10 @@ Pulse is a general purpose, cross-platform, performance-oriented, command-line u - Supports Headers - Support Content-Type and Body for POST, PUT, PATCH, and DELETE - Custom HTML generated outputs for easy inspection +- Structured output toggle (PlainText or JSON) for terminals, scripts, and LLMs - Format JSON outputs - Captures all response headers for debugging +- Quiet mode to silence progress noise when piping or scripting And more! @@ -117,6 +119,8 @@ Arguments: Options: --json Try to format response content as JSON (Optional) --raw Export raw results [without wrapping in custom HTML] (Optional) + --output-format Output as PlainText|JSON (Default: PlainText) + --quiet Suppress progress output on stderr (only fatal errors will be shown) (Default: False) -f, --full-equality Use full equality [slower] (Optional) --no-export Don't export results (Optional) -v, --verbose Display verbose output (Optional) @@ -138,6 +142,8 @@ Commands: - `--json` - try to format response content as JSON. - `--raw` - export raw results without custom HTML; can be combined with `--json`. +- `--output-format PlainText|JSON` (global) - choose human-readable console output or structured JSON for automation/LLMs. +- `--quiet` (global) - suppress progress updates on stderr; only fatal errors remain. Useful when piping to `jq` or when stderr/stdout are merged. - `-f|--full-equality` - enforce full response equality checks instead of length-based comparisons. - `-v|--verbose` - display per-request logging instead of the dashboard UI. - `--no-op` - print the parsed configuration without running any requests. From 1b2b1367310a3f885e72e3667e594d403435005c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:52:37 +0200 Subject: [PATCH 079/105] Update agents.md --- Agents.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Agents.md b/Agents.md index b59d376..1abfb85 100644 --- a/Agents.md +++ b/Agents.md @@ -10,16 +10,23 @@ ### Command & Orchestration Agent - **Files:** `src/Pulse/Program.cs`, `src/Pulse/Core/Commands.cs` - Registers the command surface (`Pulse`, `get-sample`, `get-schema`, `update`, `terms-of-use`) and wires a global exception filter. -- Adds a global `--llm` flag that toggles structured JSON output for every command by populating `GlobalOptions` with an `OutputFormat` value. +- Adds global options: + - `--output-format` (PlainText|JSON) to select human-readable vs structured output for all commands. + - `--quiet` to silence progress reporting on stderr while still allowing fatal errors. - `Commands.Root` parses CLI inputs into `ParametersBase`, loads request definitions from disk via `InputJsonContext`, and triggers the execution pipeline. - Hosts helper commands that generate request samples and JSON Schema artifacts, print terms of use, and query GitHub releases for updates. ### Output Formatting Agent - **Files:** `src/Pulse/Models/IOutputFormatter.cs`, `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Core/Helper.cs`, `src/Pulse/Models/*Model.cs` - `IOutputFormatter` defines paired `OutputAsPlainText` / `OutputAsJson` methods with an `Output(OutputFormat)` extension to centralize human-vs-LLM rendering. -- `OutputFormat` (PlainText/JSON) is derived from the global `--llm` switch via `Helper.OutputFormatFromBool` and stored in `GlobalOptions` for all commands. +- `OutputFormat` (PlainText/JSON) is provided by the global `--output-format` option and stored in `GlobalOptions` for all commands. - User-facing models (`RunConfiguration`, `SummaryModel`, `TermsOfServiceModel`, `CheckForUpdatesModel`, `GetSampleModel`) implement the interface so the same execution flow can emit colored console text or serialized JSON. +### Quiet/Progress Agent +- **Files:** `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Models/Parameters.cs`, `src/Pulse/Core/PulseMonitor.cs`, `src/Pulse/Core/VerbosePulseMonitor.cs`, `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/GlobalExceptionHandler.cs` +- The global `--quiet` option flows into `Parameters.Quiet` to suppress progress dashboards and per-request logs on stderr while keeping fatal/error reporting intact. +- Progress printers in both monitors and cross-referencing logs in `PulseSummary` honor this flag; the exception handler clears progress regions only when they were rendered. + ### Configuration Agent - **Files:** `src/Pulse/Configuration/InputJsonContext.cs`, `src/Pulse/Configuration/DefaultJsonContext.cs`, `src/Pulse/Configuration/Parameters.cs`, `src/Pulse/Core/RequestDetails.cs` - Source generators (`JsonSerializerContext`) provide strongly typed serializers for request payloads, headers, exceptions, and release metadata. @@ -74,11 +81,11 @@ - `VersionTests.cs` keep `Commands.VERSION` synchronized with assembly metadata. ## Data Flow Snapshot -1. User invokes the CLI; the global `--llm` flag sets `GlobalOptions.OutputFormat`, and `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. +1. User invokes the CLI; global options set `GlobalOptions.OutputFormat` and `Quiet`, then `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. 2. `Pulse.RunAsync` creates proxy-aware HTTP plumbing, chooses a monitor (verbose or dashboard), and schedules the requested workload with semaphore-throttled concurrency. 3. `RequestExecutionContext` issues HTTP requests, captures responses or exceptions, and records latency and concurrency metrics inside `Response`. -4. Monitors update live console feedback and accumulate results before handing off a `PulseResult`. -5. `PulseSummary` materializes `SummaryModel` and other `IOutputFormatter` models, printing either plaintext or JSON, then deduplicates responses and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder. +4. Monitors update live console feedback (unless `Quiet`) and accumulate results before handing off a `PulseResult`. +5. `PulseSummary` materializes `SummaryModel` and other `IOutputFormatter` models, printing either plaintext or JSON, then deduplicates responses and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder; summary progress lines are suppressed when `Quiet`. 6. `GlobalExceptionHandler` guarantees graceful shutdown, while optional commands (`get-sample`, `get-schema`, `update`, `terms-of-use`) reuse serialization agents for auxiliary workflows. ## External Dependencies From fd9a3813370bded01ad13c6ca31c13e55ab47253 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sat, 22 Nov 2025 16:53:26 +0200 Subject: [PATCH 080/105] Formatting --- src/Pulse/Core/CliSchemaCommand.cs | 28 ++++----- src/Pulse/Core/Commands.cs | 2 +- src/Pulse/Core/ConsoleState.cs | 30 +++++----- src/Pulse/Core/GlobalExceptionHandler.cs | 2 +- src/Pulse/Core/Helper.cs | 12 ++-- src/Pulse/Core/Pulse.cs | 2 +- src/Pulse/Core/PulseMonitor.cs | 2 +- src/Pulse/Core/PulseSummary.cs | 42 +++++++------- src/Pulse/GlobalUsings.cs | 2 +- src/Pulse/Models/CheckForUpdatesModel.cs | 36 ++++++------ src/Pulse/Models/GetSampleModel.cs | 4 +- src/Pulse/Models/IOutputFormatter.cs | 28 ++++----- src/Pulse/Models/MinMeanMax.cs | 6 +- src/Pulse/Models/ModelsJsonContext.cs | 16 +++--- src/Pulse/Models/RunConfiguration.cs | 14 ++--- src/Pulse/Models/StrippedException.cs | 8 +-- src/Pulse/Models/SummaryModel.cs | 72 ++++++++++++------------ src/Pulse/Models/Target.cs | 4 +- src/Pulse/Models/TermsOfServiceModel.cs | 26 ++++----- src/Pulse/Program.cs | 6 +- tests/Pulse.Tests.Unit/SummaryTests.cs | 2 +- 21 files changed, 170 insertions(+), 174 deletions(-) diff --git a/src/Pulse/Core/CliSchemaCommand.cs b/src/Pulse/Core/CliSchemaCommand.cs index 93e2246..d3e7ea2 100644 --- a/src/Pulse/Core/CliSchemaCommand.cs +++ b/src/Pulse/Core/CliSchemaCommand.cs @@ -5,20 +5,20 @@ namespace Pulse.Core; internal sealed class CliSchemaCommand { - private readonly ConsoleApp.ConsoleAppBuilder _app; + private readonly ConsoleApp.ConsoleAppBuilder _app; - public CliSchemaCommand(ConsoleApp.ConsoleAppBuilder app) { - _app = app; - } + public CliSchemaCommand(ConsoleApp.ConsoleAppBuilder app) { + _app = app; + } - /// - /// Returns the usage schema for the app in JSON format. - /// - /// - public int Command() { - CommandHelpDefinition[] schema = _app.GetCliSchema(); - ReadOnlySpan json = JsonSerializer.Serialize(schema, CliSchemaJsonSerializerContext.Default.CommandHelpDefinitionArray); - Console.WriteLine(json); - return 0; - } + /// + /// Returns the usage schema for the app in JSON format. + /// + /// + public int Command() { + CommandHelpDefinition[] schema = _app.GetCliSchema(); + ReadOnlySpan json = JsonSerializer.Serialize(schema, CliSchemaJsonSerializerContext.Default.CommandHelpDefinitionArray); + Console.WriteLine(json); + return 0; + } } \ No newline at end of file diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 7ebaa4b..8b5d75c 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -196,4 +196,4 @@ internal static void PrintConfiguration(Parameters parameters, RequestDetails re configuration.Output(parameters.OutputFormat); } -} +} \ No newline at end of file diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs index e3b036c..8a670c5 100644 --- a/src/Pulse/Core/ConsoleState.cs +++ b/src/Pulse/Core/ConsoleState.cs @@ -7,21 +7,21 @@ public static int LinesWritten { } public static void Reset(int startLine) { - LinesWritten = startLine; - } + LinesWritten = startLine; + } - public static void ReportLinesFromCurrent(int lineCount) { - if (lineCount <= 0) { - return; - } + public static void ReportLinesFromCurrent(int lineCount) { + if (lineCount <= 0) { + return; + } - int current = Console.GetCurrentLine(); - int lastLine = current + lineCount - 1; - UpdateMax(lastLine); - } + int current = Console.GetCurrentLine(); + int lastLine = current + lineCount - 1; + UpdateMax(lastLine); + } - private static void UpdateMax(int candidate) { - if (LinesWritten >= candidate) return; - LinesWritten = candidate; - } -} + private static void UpdateMax(int candidate) { + if (LinesWritten >= candidate) return; + LinesWritten = candidate; + } +} \ No newline at end of file diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index 2eff467..ae354c4 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -47,4 +47,4 @@ static void ClearFrom(int start) { } } -#pragma warning restore CA1031 // Do not catch general exception types +#pragma warning restore CA1031 // Do not catch general exception types \ No newline at end of file diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index b3e5b9b..771401b 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -15,17 +15,15 @@ public static double Percentage(T current, T total) where T : INumberBase } public static TimeSpan GetEta(double percentage, TimeSpan elapsed) { - switch (percentage) - { + switch (percentage) { case <= 0: return TimeSpan.MaxValue; case >= 1: return TimeSpan.Zero; - default: - { - var rem = (1 - percentage) / percentage; - return rem * elapsed; - } + default: { + var rem = (1 - percentage) / percentage; + return rem * elapsed; + } } } diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index e292a47..0dca129 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -49,4 +49,4 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD await PulseSummary.SummarizeAsync(parameters, requestDetails, result).ConfigureAwait(false); } -} +} \ No newline at end of file diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 8b60cd4..4e89dce 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -163,4 +163,4 @@ public async Task ClearAndReturnAsync() { TotalDuration = Stopwatch.GetElapsedTime(_start) }; } -} +} \ No newline at end of file diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index dce38ea..2c35842 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -101,7 +101,7 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai if (parameters.Export) { await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); - } + } } /// @@ -158,31 +158,29 @@ internal static async ValueTask SummarizeSingleAsync(Parameters parameters, Requ /// /// internal static Summary GetSummary(Span values, bool removeOutliers = true) { - switch (values.Length) - { - case > 2: - { - values.Sort(); - - if (!removeOutliers) { - return SummarizeOrderedSpan(values, 0); - } + switch (values.Length) { + case > 2: { + values.Sort(); - int i25 = values.Length / 4, i75 = 3 * values.Length / 4; - double q1 = values[i25]; // First quartile + if (!removeOutliers) { + return SummarizeOrderedSpan(values, 0); + } - double q3 = values[i75]; // Third quartile + int i25 = values.Length / 4, i75 = 3 * values.Length / 4; + double q1 = values[i25]; // First quartile - double iqr = q3 - q1; - double lowerBound = q1 - 1.5 * iqr; - double upperBound = q3 + 1.5 * iqr; + double q3 = values[i75]; // Third quartile - int start = FindBoundIndex(values, lowerBound, 0, i25); - int end = FindBoundIndex(values, upperBound, i75, values.Length); - ReadOnlySpan filtered = values.Slice(start, end - start); + double iqr = q3 - q1; + double lowerBound = q1 - 1.5 * iqr; + double upperBound = q3 + 1.5 * iqr; - return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); - } + int start = FindBoundIndex(values, lowerBound, 0, i25); + int end = FindBoundIndex(values, upperBound, i75, values.Length); + ReadOnlySpan filtered = values.Slice(start, end - start); + + return SummarizeOrderedSpan(filtered, values.Length - filtered.Length); + } case 2: return new Summary { Min = Math.Min(values[0], values[1]), @@ -294,4 +292,4 @@ internal static async Task ExportUniqueRequestsAsync(Parameters parameters, Hash Console.WriteLineInterpolated($"{Green}{count}{ConsoleColor.Default} unique responses exported to {Yellow}{directory}{ConsoleColor.Default}"); } -} +} \ No newline at end of file diff --git a/src/Pulse/GlobalUsings.cs b/src/Pulse/GlobalUsings.cs index 832bbd2..b28e0cc 100644 --- a/src/Pulse/GlobalUsings.cs +++ b/src/Pulse/GlobalUsings.cs @@ -2,4 +2,4 @@ global using PrettyConsole; -global using static System.ConsoleColor; +global using static System.ConsoleColor; \ No newline at end of file diff --git a/src/Pulse/Models/CheckForUpdatesModel.cs b/src/Pulse/Models/CheckForUpdatesModel.cs index b4ee185..fd4cb59 100644 --- a/src/Pulse/Models/CheckForUpdatesModel.cs +++ b/src/Pulse/Models/CheckForUpdatesModel.cs @@ -3,28 +3,28 @@ namespace Pulse.Models; internal readonly struct CheckForUpdatesModel : IOutputFormatter { - public required Version CurrentVersion { get; init; } - public required Version RemoteVersion { get; init; } - public required bool UpdateRequired { get; init; } - public string RemoteRepository { get; init; } = RemoteRepositoryUrl; - private const string RemoteRepositoryUrl = "https://github.com/dusrdev/Pulse/releases/latest"; + public required Version CurrentVersion { get; init; } + public required Version RemoteVersion { get; init; } + public required bool UpdateRequired { get; init; } + public string RemoteRepository { get; init; } = RemoteRepositoryUrl; + private const string RemoteRepositoryUrl = "https://github.com/dusrdev/Pulse/releases/latest"; public CheckForUpdatesModel() { } public void OutputAsJson() { - JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.CheckForUpdatesModel); - } + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.CheckForUpdatesModel); + } - public void OutputAsPlainText() { - if (UpdateRequired) { - Console.WriteLineInterpolated($"{Yellow}A new version of Pulse is available!"); - Console.WriteLineInterpolated($"Your version: {Markup.Underline}{Yellow}{CurrentVersion}{Markup.ResetUnderline}"); - Console.WriteLineInterpolated($"Latest version: {Markup.Underline}{Green}{RemoteVersion}{Markup.ResetUnderline}"); - Console.NewLine(); - Console.WriteLineInterpolated($"Download from {Markup.Underline}{RemoteRepository}{Markup.ResetUnderline}"); - } else { - Console.WriteLineInterpolated($"{Green}You are using the latest version of Pulse."); - } - } + public void OutputAsPlainText() { + if (UpdateRequired) { + Console.WriteLineInterpolated($"{Yellow}A new version of Pulse is available!"); + Console.WriteLineInterpolated($"Your version: {Markup.Underline}{Yellow}{CurrentVersion}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"Latest version: {Markup.Underline}{Green}{RemoteVersion}{Markup.ResetUnderline}"); + Console.NewLine(); + Console.WriteLineInterpolated($"Download from {Markup.Underline}{RemoteRepository}{Markup.ResetUnderline}"); + } else { + Console.WriteLineInterpolated($"{Green}You are using the latest version of Pulse."); + } + } } \ No newline at end of file diff --git a/src/Pulse/Models/GetSampleModel.cs b/src/Pulse/Models/GetSampleModel.cs index 132d7a0..cb8dfb3 100644 --- a/src/Pulse/Models/GetSampleModel.cs +++ b/src/Pulse/Models/GetSampleModel.cs @@ -3,10 +3,10 @@ namespace Pulse.Models; internal readonly struct GetSampleModel : IOutputFormatter { - public required string Path { get; init; } + public required string Path { get; init; } public void OutputAsJson() { - JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.GetSampleModel); + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.GetSampleModel); } public void OutputAsPlainText() { diff --git a/src/Pulse/Models/IOutputFormatter.cs b/src/Pulse/Models/IOutputFormatter.cs index 53ef966..560e023 100644 --- a/src/Pulse/Models/IOutputFormatter.cs +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -7,21 +7,21 @@ internal interface IOutputFormatter { } internal enum OutputFormat { - PlainText, - JSON + PlainText, + JSON } internal static class OutputFormatterExtensions { - internal static void Output(this IOutputFormatter value, OutputFormat format) { - switch (format) { - case OutputFormat.PlainText: - value.OutputAsPlainText(); - break; - case OutputFormat.JSON: - value.OutputAsJson(); - break; - default: - throw new ArgumentOutOfRangeException(nameof(format)); - } - } + internal static void Output(this IOutputFormatter value, OutputFormat format) { + switch (format) { + case OutputFormat.PlainText: + value.OutputAsPlainText(); + break; + case OutputFormat.JSON: + value.OutputAsJson(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(format)); + } + } } \ No newline at end of file diff --git a/src/Pulse/Models/MinMeanMax.cs b/src/Pulse/Models/MinMeanMax.cs index 0755e70..ffe0569 100644 --- a/src/Pulse/Models/MinMeanMax.cs +++ b/src/Pulse/Models/MinMeanMax.cs @@ -1,7 +1,7 @@ namespace Pulse.Models; internal readonly struct MinMeanMax { - public required double Min { get; init; } - public required double Mean { get; init; } - public required double Max { get; init; } + public required double Min { get; init; } + public required double Mean { get; init; } + public required double Max { get; init; } } \ No newline at end of file diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index 9047bca..7fff322 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -17,12 +17,12 @@ namespace Pulse.Models; internal partial class ModelsJsonContext : JsonSerializerContext; internal static class JsonSerializerExtensions { - extension(JsonSerializer) { - internal static void ToConsoleOut(in T value, JsonTypeInfo jsonTypeInfo) { - using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); - JsonSerializer.Serialize(writer, value, jsonTypeInfo); - writer.Flush(); - Console.WriteLine(); - } - } + extension(JsonSerializer) { + internal static void ToConsoleOut(in T value, JsonTypeInfo jsonTypeInfo) { + using var writer = new Utf8JsonWriter(Console.OpenStandardOutput()); + JsonSerializer.Serialize(writer, value, jsonTypeInfo); + writer.Flush(); + Console.WriteLine(); + } + } } \ No newline at end of file diff --git a/src/Pulse/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs index 0eb53cd..0488dc1 100644 --- a/src/Pulse/Models/RunConfiguration.cs +++ b/src/Pulse/Models/RunConfiguration.cs @@ -3,15 +3,15 @@ namespace Pulse.Models; internal readonly struct RunConfiguration : IOutputFormatter { - public required Parameters Parameters { get; init; } - public required RequestDetails RequestDetails { get; init; } + public required Parameters Parameters { get; init; } + public required RequestDetails RequestDetails { get; init; } public void OutputAsJson() { - JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.RunConfiguration); + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.RunConfiguration); } public void OutputAsPlainText() { - ConsoleColor headerColor = Cyan; + ConsoleColor headerColor = Cyan; ConsoleColor property = DarkGray; ConsoleColor value = White; @@ -25,9 +25,9 @@ public void OutputAsPlainText() { Console.WriteLineInterpolated($"{property} Format JSON: {value}{Parameters.FormatJson}"); Console.WriteLineInterpolated($"{property} Export Full Equality: {value}{Parameters.UseFullEquality}"); Console.WriteLineInterpolated($"{property} Export: {value}{Parameters.Export}"); - Console.WriteLineInterpolated($"{property} Verbose: {value}{Parameters.Verbose}"); - Console.WriteLineInterpolated($"{property} OutputFormat: {value}{Parameters.OutputFormat}"); - Console.WriteLineInterpolated($"{property} Quiet: {value}{Parameters.Quiet}"); + Console.WriteLineInterpolated($"{property} Verbose: {value}{Parameters.Verbose}"); + Console.WriteLineInterpolated($"{property} OutputFormat: {value}{Parameters.OutputFormat}"); + Console.WriteLineInterpolated($"{property} Quiet: {value}{Parameters.Quiet}"); Console.WriteLineInterpolated($"{property} Output Folder: {value}{Parameters.OutputFolder}"); // Request diff --git a/src/Pulse/Models/StrippedException.cs b/src/Pulse/Models/StrippedException.cs index 8b642a4..33dd351 100644 --- a/src/Pulse/Models/StrippedException.cs +++ b/src/Pulse/Models/StrippedException.cs @@ -52,13 +52,13 @@ public static StrippedException FromException(Exception? exception) { public void OutputAsPlainText() { if (Type == nameof(OperationCanceledException)) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); - } else { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Cancellation requested and handled gracefully."); + } else { + Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}Unexpected exception! Please contact developer at: {Markup.Underline}https://dusrdev.github.io{Markup.ResetUnderline}"); Console.WriteLineInterpolated(OutputPipe.Error, $"{Red}and provide the following details:"); Console.NewLine(OutputPipe.Error); this.PrintException(); - } + } } public void OutputAsJson() { diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs index 6276279..b62a050 100644 --- a/src/Pulse/Models/SummaryModel.cs +++ b/src/Pulse/Models/SummaryModel.cs @@ -6,42 +6,42 @@ namespace Pulse.Models; internal readonly struct SummaryModel : IOutputFormatter { - public required Target Target { get; init; } - public required int RequestCount { get; init; } - public required int ConcurrentConnections { get; init; } - public required TimeSpan TotalDuration { get; init; } - public required double SuccessRate { get; init; } - public required MinMeanMax LatencyInMilliseconds { get; init; } - public required int LatencyOutliersRemoved { get; init; } - public required MinMeanMax ContentSizeInBytes { get; init; } - public required double ThroughputBytesPerSecond { get; init; } - public required Dictionary StatusCodeCounts { get; init; } + public required Target Target { get; init; } + public required int RequestCount { get; init; } + public required int ConcurrentConnections { get; init; } + public required TimeSpan TotalDuration { get; init; } + public required double SuccessRate { get; init; } + public required MinMeanMax LatencyInMilliseconds { get; init; } + public required int LatencyOutliersRemoved { get; init; } + public required MinMeanMax ContentSizeInBytes { get; init; } + public required double ThroughputBytesPerSecond { get; init; } + public required Dictionary StatusCodeCounts { get; init; } - public void OutputAsJson() { - JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.SummaryModel); - } + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.SummaryModel); + } - public void OutputAsPlainText() { - Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(Target.HttpMethod)}{Target.HttpMethod}{ConsoleColor.Default} => {Markup.Underline}{Target.Url}{Markup.ResetUnderline}"); - Console.WriteLineInterpolated($"Request count: {Yellow}{RequestCount}"); - Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); - Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); - Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(SuccessRate)}{SuccessRate}%"); - Console.WriteLineInterpolated($"Latency: Min: {Green}{LatencyInMilliseconds.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); - if (LatencyOutliersRemoved != 0) { - Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); - } - Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSizeInBytes.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSizeInBytes.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSizeInBytes.Max:bytes}"); - Console.WriteLineInterpolated($"Total throughput: {Yellow}{ThroughputBytesPerSecond:bytes}/s"); - Console.WriteLineInterpolated($"Status codes:"); - foreach (var kvp in StatusCodeCounts.OrderBy(static s => (int)s.Key)) { - var key = (int)kvp.Key; - if (key is 0) { - Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); - } else { - Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); - } - } - Console.NewLine(); - } + public void OutputAsPlainText() { + Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(Target.HttpMethod)}{Target.HttpMethod}{ConsoleColor.Default} => {Markup.Underline}{Target.Url}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"Request count: {Yellow}{RequestCount}"); + Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); + Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); + Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(SuccessRate)}{SuccessRate}%"); + Console.WriteLineInterpolated($"Latency: Min: {Green}{LatencyInMilliseconds.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); + if (LatencyOutliersRemoved != 0) { + Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); + } + Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSizeInBytes.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSizeInBytes.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSizeInBytes.Max:bytes}"); + Console.WriteLineInterpolated($"Total throughput: {Yellow}{ThroughputBytesPerSecond:bytes}/s"); + Console.WriteLineInterpolated($"Status codes:"); + foreach (var kvp in StatusCodeCounts.OrderBy(static s => (int)s.Key)) { + var key = (int)kvp.Key; + if (key is 0) { + Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); + } else { + Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); + } + } + Console.NewLine(); + } } \ No newline at end of file diff --git a/src/Pulse/Models/Target.cs b/src/Pulse/Models/Target.cs index 54c1402..f883a08 100644 --- a/src/Pulse/Models/Target.cs +++ b/src/Pulse/Models/Target.cs @@ -1,6 +1,6 @@ namespace Pulse.Models; internal readonly struct Target { - public string HttpMethod { get; init; } - public string Url { get; init; } + public string HttpMethod { get; init; } + public string Url { get; init; } } \ No newline at end of file diff --git a/src/Pulse/Models/TermsOfServiceModel.cs b/src/Pulse/Models/TermsOfServiceModel.cs index 246c0ab..ceed37d 100644 --- a/src/Pulse/Models/TermsOfServiceModel.cs +++ b/src/Pulse/Models/TermsOfServiceModel.cs @@ -4,19 +4,19 @@ namespace Pulse.Models; internal readonly struct TermsOfServiceModel : IOutputFormatter { - private static ImmutableArray Lines => ImmutableArray.Create([ - "By using this tool you agree to take full responsibility for the consequences of its use.", - "Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws.", - "The developers assume no liability and are not responsible for any misuse or damage caused by this program." - ]); + private static ImmutableArray Lines => ImmutableArray.Create([ + "By using this tool you agree to take full responsibility for the consequences of its use.", + "Usage of this tool for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws.", + "The developers assume no liability and are not responsible for any misuse or damage caused by this program." + ]); - public void OutputAsJson() { - JsonSerializer.ToConsoleOut(Lines, ModelsJsonContext.Default.ImmutableArrayString); - } + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(Lines, ModelsJsonContext.Default.ImmutableArrayString); + } - public void OutputAsPlainText() { - foreach (var line in Lines) { - Console.WriteLineInterpolated($"{line}"); - } - } + public void OutputAsPlainText() { + foreach (var line in Lines) { + Console.WriteLineInterpolated($"{line}"); + } + } } \ No newline at end of file diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 5b3a9ed..58d3cec 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -10,9 +10,9 @@ app.UseFilter(); app.ConfigureGlobalOptions((ref builder) => { - var format = builder.AddGlobalOption("--output-format", description: "Output as PlainText|JSON", defaultValue: OutputFormat.PlainText); - var quiet = builder.AddGlobalOption("--quiet", description: "Suppress progress output on stderr (only fatal errors will be shown).", defaultValue: false); - return new GlobalOptions(format, quiet); + var format = builder.AddGlobalOption("--output-format", description: "Output as PlainText|JSON", defaultValue: OutputFormat.PlainText); + var quiet = builder.AddGlobalOption("--quiet", description: "Suppress progress output on stderr (only fatal errors will be shown).", defaultValue: false); + return new GlobalOptions(format, quiet); }); app.Add("", Commands.Root); diff --git a/tests/Pulse.Tests.Unit/SummaryTests.cs b/tests/Pulse.Tests.Unit/SummaryTests.cs index e6f128e..b31a903 100644 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ b/tests/Pulse.Tests.Unit/SummaryTests.cs @@ -113,4 +113,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} +} \ No newline at end of file From 03f00edffcca84172c9ae07dad859692b66f386b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 12:16:18 +0200 Subject: [PATCH 081/105] Update prettyConsole --- src/Pulse/Pulse.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index e659b4b..7bf9df0 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -34,7 +34,7 @@ all - + From 06915e7ac522921fc12b0bdb83dd708cfcb654dc Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 13:29:42 +0200 Subject: [PATCH 082/105] Migrate tests to TUnit --- Pulse.slnx | 8 +- profiling/runner.cs | 2 +- src/Pulse/Pulse.csproj | 4 +- tests/Pulse.Tests.Unit/Assembly.cs | 1 - tests/Pulse.Tests.Unit/GlobalUsings.cs | 1 - tests/Pulse.Tests.Unit/HelperTests.cs | 38 ---- .../HttpClientFactoryTests.cs | 125 ------------ tests/Pulse.Tests.Unit/ParametersTests.cs | 21 -- .../Pulse.Tests.Unit/Pulse.Tests.Unit.csproj | 38 ---- .../StrippedExceptionTests.cs | 52 ----- tests/Pulse.Tests.Unit/xunit.runner.json | 9 - .../ExporterTests.cs | 193 +++++++----------- tests/Pulse.Tests/HelperTests.cs | 34 +++ tests/Pulse.Tests/HttpClientFactoryTests.cs | 98 +++++++++ tests/Pulse.Tests/ParametersTests.cs | 18 ++ tests/Pulse.Tests/Pulse.Tests.csproj | 24 +++ .../PulseMonitorTests.cs | 10 +- .../ResponseComparerTests.cs | 24 +-- tests/Pulse.Tests/StrippedExceptionTests.cs | 43 ++++ .../SummaryTests.cs | 73 +++---- .../VersionTests.cs | 12 +- 21 files changed, 342 insertions(+), 486 deletions(-) delete mode 100644 tests/Pulse.Tests.Unit/Assembly.cs delete mode 100644 tests/Pulse.Tests.Unit/GlobalUsings.cs delete mode 100644 tests/Pulse.Tests.Unit/HelperTests.cs delete mode 100644 tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs delete mode 100644 tests/Pulse.Tests.Unit/ParametersTests.cs delete mode 100644 tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj delete mode 100644 tests/Pulse.Tests.Unit/StrippedExceptionTests.cs delete mode 100644 tests/Pulse.Tests.Unit/xunit.runner.json rename tests/{Pulse.Tests.Unit => Pulse.Tests}/ExporterTests.cs (73%) create mode 100644 tests/Pulse.Tests/HelperTests.cs create mode 100644 tests/Pulse.Tests/HttpClientFactoryTests.cs create mode 100644 tests/Pulse.Tests/ParametersTests.cs create mode 100644 tests/Pulse.Tests/Pulse.Tests.csproj rename tests/{Pulse.Tests.Unit => Pulse.Tests}/PulseMonitorTests.cs (80%) rename tests/{Pulse.Tests.Unit => Pulse.Tests}/ResponseComparerTests.cs (72%) create mode 100644 tests/Pulse.Tests/StrippedExceptionTests.cs rename tests/{Pulse.Tests.Unit => Pulse.Tests}/SummaryTests.cs (50%) rename tests/{Pulse.Tests.Unit => Pulse.Tests}/VersionTests.cs (51%) diff --git a/Pulse.slnx b/Pulse.slnx index 0d234d9..d991d63 100644 --- a/Pulse.slnx +++ b/Pulse.slnx @@ -1,4 +1,8 @@ - - + + + + + + diff --git a/profiling/runner.cs b/profiling/runner.cs index 051313d..34f50d2 100644 --- a/profiling/runner.cs +++ b/profiling/runner.cs @@ -1,4 +1,4 @@ -#:package PrettyConsole@5.0.0 +#:package PrettyConsole@5.1.0 #:package ConsoleAppFramework@5.7.11 #:package CliWrap@3.10.0 diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 7bf9df0..f22e125 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -39,8 +39,8 @@ - <_Parameter1>Pulse.Tests.Unit + <_Parameter1>Pulse.Tests - \ No newline at end of file + diff --git a/tests/Pulse.Tests.Unit/Assembly.cs b/tests/Pulse.Tests.Unit/Assembly.cs deleted file mode 100644 index 6946172..0000000 --- a/tests/Pulse.Tests.Unit/Assembly.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: CaptureConsole] \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/GlobalUsings.cs b/tests/Pulse.Tests.Unit/GlobalUsings.cs deleted file mode 100644 index e11d745..0000000 --- a/tests/Pulse.Tests.Unit/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Bogus; \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/HelperTests.cs b/tests/Pulse.Tests.Unit/HelperTests.cs deleted file mode 100644 index 7b4aeb6..0000000 --- a/tests/Pulse.Tests.Unit/HelperTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Net; - -using Pulse.Core; - -namespace Pulse.Tests.Unit; - -public class HelperTests { - [Theory] - [InlineData(100, ConsoleColor.Green)] - [InlineData(80, ConsoleColor.Green)] - [InlineData(75, ConsoleColor.Green)] - [InlineData(60, ConsoleColor.Yellow)] - [InlineData(50, ConsoleColor.Yellow)] - [InlineData(40, ConsoleColor.Red)] - [InlineData(0, ConsoleColor.Red)] - public void Extensions_GetPercentageBasedColor(double percentage, ConsoleColor expected) { - // Arrange - var color = Helper.GetPercentageBasedColor(percentage); - - // Assert - Assert.Equal(expected, color); - } - - [Theory] - [InlineData(HttpStatusCode.OK, ConsoleColor.Green)] - [InlineData((HttpStatusCode)0, ConsoleColor.Magenta)] - [InlineData(HttpStatusCode.Forbidden, ConsoleColor.Red)] - [InlineData(HttpStatusCode.BadGateway, ConsoleColor.Red)] - [InlineData(HttpStatusCode.Ambiguous, ConsoleColor.Yellow)] - [InlineData(HttpStatusCode.PermanentRedirect, ConsoleColor.Yellow)] - public void Extensions_GetStatusCodeBasedColor(HttpStatusCode statusCode, ConsoleColor expected) { - // Arrange - var color = Helper.GetStatusCodeBasedColor((int)statusCode); - - // Assert - Assert.Equal(expected, color); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs b/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs deleted file mode 100644 index 2ea8997..0000000 --- a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Net; - -using Pulse.Configuration; - -using Pulse.Core; -using Pulse.Models; - -namespace Pulse.Tests.Unit; - -public class HttpClientFactoryTests { - [Fact] - public void HttpClientFactory_DefaultTimeout_IsInfinite() { - // Arrange - var proxy = new Proxy(); - - // Act - using var httpClient = PulseHttpClientFactory.Create(proxy, -1); - - // Assert - Assert.Equal(Timeout.InfiniteTimeSpan, httpClient.Timeout); - } - - [Fact] - public void HttpClientFactory_WithoutProxy_ReturnsHttpClient() { - // Arrange - var proxy = new Proxy(); - - // Act - using var httpClient = PulseHttpClientFactory.Create(proxy, -1); - - // Assert - Assert.NotNull(httpClient); - } - - [Fact] - public void CreateHandler_WithoutProxy_ReturnsSocketsHttpHandler() { - // Arrange - var proxy = new Proxy(); - - // Act - using var handler = PulseHttpClientFactory.CreateHandler(proxy); - - // Assert - Assert.NotNull(handler); - } - - [Fact] - public void CreateHandler_WithoutProxy() { - // Arrange - var proxy = new Proxy(); - - // Act - using var handler = PulseHttpClientFactory.CreateHandler(proxy); - - // Assert - Assert.Null(handler.Proxy); - } - - [Theory] - [InlineData("127.0.0.1:8080", "127.0.0.1:8080")] - public void CreateHandler_WithProxy_HostOnly(string host, string expected) { - // Arrange - var proxy = new Proxy() { - Bypass = false, - Host = host, - }; - - // Act - using var handler = PulseHttpClientFactory.CreateHandler(proxy); - - // Assert - Assert.True(handler.UseProxy); - Assert.NotNull(handler.Proxy); - - // Create a valid destination Uri - var destination = new Uri("http://example.com"); - - // Retrieve the proxy Uri for the given destination - var proxyUri = handler.Proxy!.GetProxy(destination); - - // Assert that the Authority (host:port) matches the expected value - Assert.Equal(expected, proxyUri!.Authority); - } - - [Fact] - public void CreateHandler_WithProxy_WithoutPassword_NoCredentials() { - // Arrange - var proxy = new Proxy { - Bypass = false, - Host = "127.0.0.1:8080", - Username = "username", - }; - - // Act - using var handler = PulseHttpClientFactory.CreateHandler(proxy); - - // Assert - Assert.True(handler.UseProxy); - Assert.NotNull(handler.Proxy); - Assert.Null(handler.Proxy!.Credentials); - } - - [Fact] - public void CreateHandler_WithProxy_WithCredentials() { - // Arrange - var proxy = new Proxy { - Bypass = false, - Host = "127.0.0.1:8080", - Username = "username", - Password = "password", - }; - - // Act - using var handler = PulseHttpClientFactory.CreateHandler(proxy); - - // Assert - Assert.True(handler.UseProxy); - Assert.NotNull(handler.Proxy); - Assert.NotNull(handler.Proxy!.Credentials); - var credentials = handler.Proxy!.Credentials! as NetworkCredential; - Assert.NotNull(credentials); - Assert.Equal(proxy.Username, credentials.UserName); - Assert.Equal(proxy.Password, credentials.Password); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/ParametersTests.cs b/tests/Pulse.Tests.Unit/ParametersTests.cs deleted file mode 100644 index 75bcd58..0000000 --- a/tests/Pulse.Tests.Unit/ParametersTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Pulse.Configuration; -using Pulse.Models; - -namespace Pulse.Tests.Unit; - -public class ParametersTests { - [Fact] - public void ParametersBase_Default() { - // Arrange - var @params = new ParametersBase(); - - // Assert - Assert.Equal(1, @params.Requests); - Assert.Equal(1, @params.Connections); - Assert.False(@params.FormatJson); - Assert.False(@params.UseFullEquality); - Assert.True(@params.Export); - Assert.False(@params.NoOp); - Assert.False(@params.Verbose); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj b/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj deleted file mode 100644 index ec83dc8..0000000 --- a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj +++ /dev/null @@ -1,38 +0,0 @@ - - - - enable - enable - Exe - Pulse.Tests.Unit - net10.0 - true - true - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/StrippedExceptionTests.cs b/tests/Pulse.Tests.Unit/StrippedExceptionTests.cs deleted file mode 100644 index 3e327b1..0000000 --- a/tests/Pulse.Tests.Unit/StrippedExceptionTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Pulse.Configuration; - -namespace Pulse.Tests.Unit; - -public class StrippedExceptionTests { - [Fact] - public void StrippedException_Default_AllEmpty() { - // Arrange - var exception = StrippedException.Default; - - // Assert - Assert.True(exception.IsDefault); - Assert.Empty(exception.Type); - Assert.Empty(exception.Message); - } - - [Fact] - public void StrippedException_JsonCtor_YieldsDefault() { - // Arrange - var exception = new StrippedException(); - - // Assert - Assert.True(exception.IsDefault); - Assert.Empty(exception.Type); - Assert.Empty(exception.Message); - } - - [Fact] - public void StrippedException_FromException_NullYieldsDefault() { - // Arrange - var exception = StrippedException.FromException(null); - - // Assert - Assert.True(exception.IsDefault); - Assert.Empty(exception.Type); - Assert.Empty(exception.Message); - } - - [Fact] - public void StrippedException_FromException_ExceptionYieldsTypeAndMessage() { - // Arrange - var exception = new Exception("Test"); - - // Act - var stripped = StrippedException.FromException(exception); - - // Assert - Assert.False(stripped.IsDefault); - Assert.Equal(exception.GetType().Name, stripped.Type); - Assert.Equal(exception.Message, stripped.Message); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/xunit.runner.json b/tests/Pulse.Tests.Unit/xunit.runner.json deleted file mode 100644 index 07481e5..0000000 --- a/tests/Pulse.Tests.Unit/xunit.runner.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "diagnosticMessages": true, - "internalDiagnosticMessages": true, - "preEnumerateTheories": true, - "parallelizeAssembly": false, - "parallelizeTestCollections": false, - "showLiveOutput": false -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/ExporterTests.cs b/tests/Pulse.Tests/ExporterTests.cs similarity index 73% rename from tests/Pulse.Tests.Unit/ExporterTests.cs rename to tests/Pulse.Tests/ExporterTests.cs index 1f9aa55..7ceb5b6 100644 --- a/tests/Pulse.Tests.Unit/ExporterTests.cs +++ b/tests/Pulse.Tests/ExporterTests.cs @@ -2,16 +2,14 @@ using System.Text; using System.Text.Json; -using Pulse.Configuration; using Pulse.Core; using Pulse.Models; -namespace Pulse.Tests.Unit; +namespace Pulse.Tests; public class ExporterTests { - [Fact] - public void Exporter_ClearFiles() { - // Arrange + [Test] + public async Task Exporter_ClearFiles() { var dirInfo = Directory.CreateTempSubdirectory(); try { var faker = new Faker(); @@ -22,22 +20,18 @@ public void Exporter_ClearFiles() { File.WriteAllText(path, content); } - // Assert - Assert.Equal(10, dirInfo.GetFiles().Length); + await Assert.That(dirInfo.GetFiles().Length).IsEqualTo(10); - // Act Exporter.ClearFiles(dirInfo.FullName); - // Assert - Assert.Empty(dirInfo.GetFiles()); + await Assert.That(dirInfo.GetFiles().Length).IsEqualTo(0); } finally { dirInfo.Delete(true); } } - [Fact] - public void Exporter_ToHtmlTable_ContainsAllHeaders() { - // Arrange + [Test] + public async Task Exporter_ToHtmlTable_ContainsAllHeaders() { List>> headers = [ new("Content-Type", ["application/json"]), new("X-Custom-Header", ["value1", "value2"]) @@ -56,21 +50,18 @@ public void Exporter_ToHtmlTable_ContainsAllHeaders() { CurrentConcurrentConnections = 1 }; - // Act var fileContent = Exporter.ToHtmlTable(response.Headers); - // Assert foreach (var header in headers) { - Assert.Contains(header.Key, fileContent); + await Assert.That(fileContent.Contains(header.Key)).IsTrue(); foreach (var value in header.Value) { - Assert.Contains(value, fileContent); + await Assert.That(fileContent.Contains(value)).IsTrue(); } } } - [Fact] + [Test] public async Task Exporter_Raw_NotSuccess_ContainsAllHeadersInJson() { - // Arrange List>> headers = [ new("Content-Type", ["application/json"]), new("X-Custom-Header", ["value1", "value2"]) @@ -87,29 +78,26 @@ public async Task Exporter_Raw_NotSuccess_ContainsAllHeadersInJson() { CurrentConcurrentConnections = 1 }; - // Act var expectedFileName = $"response-1337-status-code-502.json"; - await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, string.Empty, false, CancellationToken.None); - Assert.True(File.Exists(expectedFileName)); + await Assert.That(File.Exists(expectedFileName)).IsTrue(); - var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); + var fileContent = await File.ReadAllTextAsync(expectedFileName, CancellationToken.None); - // Assert - Assert.Contains("502", fileContent); + await Assert.That(fileContent.Contains("502")).IsTrue(); foreach (var header in headers) { - Assert.Contains(header.Key, fileContent); + await Assert.That(fileContent.Contains(header.Key)).IsTrue(); foreach (var value in header.Value) { - Assert.Contains(value, fileContent); + await Assert.That(fileContent.Contains(value)).IsTrue(); } } File.Delete(expectedFileName); } - [Fact] + [Test] public async Task Exporter_Raw_Success_ContainsOnlyContent() { - // Arrange const string expectedContent = "Hello World"; var response = new Response { @@ -123,23 +111,20 @@ public async Task Exporter_Raw_Success_ContainsOnlyContent() { CurrentConcurrentConnections = 1 }; - // Act var expectedFileName = $"response-1337-status-code-200.html"; - await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, string.Empty, false, CancellationToken.None); - Assert.True(File.Exists(expectedFileName)); + await Assert.That(File.Exists(expectedFileName)).IsTrue(); - var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); + var fileContent = await File.ReadAllTextAsync(expectedFileName, CancellationToken.None); - // Assert - Assert.Equal(expectedContent, fileContent); + await Assert.That(fileContent).IsEqualTo(expectedContent); File.Delete(expectedFileName); } - [Fact] + [Test] public async Task Exporter_Raw_NotSuccess_ButHasContent_ContainsOnlyContent() { - // Arrange const string expectedContent = "Hello World"; var response = new Response { @@ -153,23 +138,20 @@ public async Task Exporter_Raw_NotSuccess_ButHasContent_ContainsOnlyContent() { CurrentConcurrentConnections = 1 }; - // Act var expectedFileName = $"response-1337-status-code-502.html"; - await Exporter.ExportRawAsync(response, string.Empty, false, TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, string.Empty, false, CancellationToken.None); - Assert.True(File.Exists(expectedFileName)); + await Assert.That(File.Exists(expectedFileName)).IsTrue(); - var fileContent = await File.ReadAllTextAsync(expectedFileName, TestContext.Current.CancellationToken); + var fileContent = await File.ReadAllTextAsync(expectedFileName, CancellationToken.None); - // Assert - Assert.Equal(expectedContent, fileContent); + await Assert.That(fileContent).IsEqualTo(expectedContent); File.Delete(expectedFileName); } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_CorrectFileName() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { const string content = "Hello World"; @@ -185,21 +167,18 @@ public async Task Exporter_ExportHtmlAsync_CorrectFileName() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - Assert.Equal("response-1337-status-code-200.html", file[0].Name); + await Assert.That(file.Length).IsEqualTo(1); + await Assert.That(file[0].Name).IsEqualTo("response-1337-status-code-200.html"); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_ContainsAllHeaders() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { List>> headers = [ @@ -220,18 +199,16 @@ public async Task Exporter_ExportHtmlAsync_ContainsAllHeaders() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); foreach (var header in headers) { - Assert.Contains(header.Key, fileContent); + await Assert.That(fileContent.Contains(header.Key)).IsTrue(); foreach (var value in header.Value) { - Assert.Contains(value, fileContent); + await Assert.That(fileContent.Contains(value)).IsTrue(); } } } finally { @@ -239,9 +216,8 @@ public async Task Exporter_ExportHtmlAsync_ContainsAllHeaders() { } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_WithoutException_HasContent() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { const string expectedContent = "Hello World"; @@ -257,22 +233,19 @@ public async Task Exporter_ExportHtmlAsync_WithoutException_HasContent() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.Contains(expectedContent, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent.Contains(expectedContent)).IsTrue(); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_RawHtml() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { const string expectedContent = "Hello World"; @@ -288,29 +261,22 @@ public async Task Exporter_ExportHtmlAsync_RawHtml() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.Equal(expectedContent, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent).IsEqualTo(expectedContent); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_RawJson() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { - var options = new JsonSerializerOptions { - WriteIndented = false - }; - - var expectedContent = JsonSerializer.Serialize(new ParametersBase(), options); + var expectedContent = JsonSerializer.Serialize(new ParametersBase(), ModelsJsonContext.Default.ParametersBase); var response = new Response { Id = 1337, @@ -323,30 +289,23 @@ public async Task Exporter_ExportHtmlAsync_RawJson() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.Equal(expectedContent, fileContent); - Assert.DoesNotContain(Environment.NewLine, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent).IsEqualTo(expectedContent); + await Assert.That(fileContent.Contains(Environment.NewLine)).IsFalse(); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_RawJson_Formatted() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { - var options = new JsonSerializerOptions { - WriteIndented = false - }; - - var content = JsonSerializer.Serialize(new ParametersBase(), options); + var content = JsonSerializer.Serialize(new ParametersBase(), ModelsJsonContext.Default.ParametersBase); var response = new Response { Id = 1337, @@ -359,22 +318,19 @@ public async Task Exporter_ExportHtmlAsync_RawJson_Formatted() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, true, TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, dirInfo.FullName, true, CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.Contains(Environment.NewLine, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent.Contains(Environment.NewLine)).IsTrue(); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_RawJson_Exception() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { var content = string.Empty; @@ -390,23 +346,20 @@ public async Task Exporter_ExportHtmlAsync_RawJson_Exception() { CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportRawAsync(response, dirInfo.FullName, true, TestContext.Current.CancellationToken); + await Exporter.ExportRawAsync(response, dirInfo.FullName, true, CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.Contains("test", fileContent); - Assert.Contains(Environment.NewLine, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent.Contains("test")).IsTrue(); + await Assert.That(fileContent.Contains(Environment.NewLine)).IsTrue(); } finally { dirInfo.Delete(true); } } - [Fact] + [Test] public async Task Exporter_ExportHtmlAsync_WithException_HasExceptionAndNoContent() { - // Arrange var dirInfo = Directory.CreateTempSubdirectory(); try { const string content = "Hello World"; @@ -423,17 +376,15 @@ public async Task Exporter_ExportHtmlAsync_WithException_HasExceptionAndNoConten CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: TestContext.Current.CancellationToken); + await Exporter.ExportHtmlAsync(response, dirInfo.FullName, token: CancellationToken.None); - // Assert var file = dirInfo.GetFiles(); - Assert.Single(file); - var fileContent = await File.ReadAllTextAsync(file[0].FullName, TestContext.Current.CancellationToken); - Assert.DoesNotContain("Hello World", fileContent); - Assert.Contains(exception.Message, fileContent); + await Assert.That(file.Length).IsEqualTo(1); + var fileContent = await File.ReadAllTextAsync(file[0].FullName, CancellationToken.None); + await Assert.That(fileContent.Contains("Hello World")).IsFalse(); + await Assert.That(fileContent.Contains(exception.Message)).IsTrue(); } finally { dirInfo.Delete(true); } } -} \ No newline at end of file +} diff --git a/tests/Pulse.Tests/HelperTests.cs b/tests/Pulse.Tests/HelperTests.cs new file mode 100644 index 0000000..f76315e --- /dev/null +++ b/tests/Pulse.Tests/HelperTests.cs @@ -0,0 +1,34 @@ +using System.Net; + +using Pulse.Core; + +namespace Pulse.Tests; + +public class HelperTests { + [Test] + [Arguments(100, ConsoleColor.Green)] + [Arguments(80, ConsoleColor.Green)] + [Arguments(75, ConsoleColor.Green)] + [Arguments(60, ConsoleColor.Yellow)] + [Arguments(50, ConsoleColor.Yellow)] + [Arguments(40, ConsoleColor.Red)] + [Arguments(0, ConsoleColor.Red)] + public async Task Extensions_GetPercentageBasedColor(double percentage, ConsoleColor expected) { + var color = Helper.GetPercentageBasedColor(percentage); + + await Assert.That(color).IsEqualTo(expected); + } + + [Test] + [Arguments(HttpStatusCode.OK, ConsoleColor.Green)] + [Arguments((HttpStatusCode)0, ConsoleColor.Magenta)] + [Arguments(HttpStatusCode.Forbidden, ConsoleColor.Red)] + [Arguments(HttpStatusCode.BadGateway, ConsoleColor.Red)] + [Arguments(HttpStatusCode.Ambiguous, ConsoleColor.Yellow)] + [Arguments(HttpStatusCode.PermanentRedirect, ConsoleColor.Yellow)] + public async Task Extensions_GetStatusCodeBasedColor(HttpStatusCode statusCode, ConsoleColor expected) { + var color = Helper.GetStatusCodeBasedColor((int)statusCode); + + await Assert.That(color).IsEqualTo(expected); + } +} diff --git a/tests/Pulse.Tests/HttpClientFactoryTests.cs b/tests/Pulse.Tests/HttpClientFactoryTests.cs new file mode 100644 index 0000000..dfbbda5 --- /dev/null +++ b/tests/Pulse.Tests/HttpClientFactoryTests.cs @@ -0,0 +1,98 @@ +using System.Net; + +using Pulse.Core; +using Pulse.Models; + +namespace Pulse.Tests; + +public class HttpClientFactoryTests { + [Test] + public async Task HttpClientFactory_DefaultTimeout_IsInfinite() { + var proxy = new Proxy(); + + using var httpClient = PulseHttpClientFactory.Create(proxy, -1); + + await Assert.That(httpClient.Timeout).IsEqualTo(Timeout.InfiniteTimeSpan); + } + + [Test] + public async Task HttpClientFactory_WithoutProxy_ReturnsHttpClient() { + var proxy = new Proxy(); + + using var httpClient = PulseHttpClientFactory.Create(proxy, -1); + + await Assert.That(httpClient).IsNotNull(); + } + + [Test] + public async Task CreateHandler_WithoutProxy_ReturnsSocketsHttpHandler() { + var proxy = new Proxy(); + + using var handler = PulseHttpClientFactory.CreateHandler(proxy); + + await Assert.That(handler).IsNotNull(); + } + + [Test] + public async Task CreateHandler_WithoutProxy() { + var proxy = new Proxy(); + + using var handler = PulseHttpClientFactory.CreateHandler(proxy); + + await Assert.That(handler.Proxy).IsNull(); + } + + [Test] + [Arguments("127.0.0.1:8080", "127.0.0.1:8080")] + public async Task CreateHandler_WithProxy_HostOnly(string host, string expected) { + var proxy = new Proxy { + Bypass = false, + Host = host, + }; + + using var handler = PulseHttpClientFactory.CreateHandler(proxy); + + await Assert.That(handler.UseProxy).IsTrue(); + await Assert.That(handler.Proxy).IsNotNull(); + + var destination = new Uri("http://example.com"); + var proxyUri = handler.Proxy!.GetProxy(destination); + + await Assert.That(proxyUri!.Authority).IsEqualTo(expected); + } + + [Test] + public async Task CreateHandler_WithProxy_WithoutPassword_NoCredentials() { + var proxy = new Proxy { + Bypass = false, + Host = "127.0.0.1:8080", + Username = "username", + }; + + using var handler = PulseHttpClientFactory.CreateHandler(proxy); + + await Assert.That(handler.UseProxy).IsTrue(); + await Assert.That(handler.Proxy).IsNotNull(); + await Assert.That(handler.Proxy!.Credentials).IsNull(); + } + + [Test] + public async Task CreateHandler_WithProxy_WithCredentials() { + var proxy = new Proxy { + Bypass = false, + Host = "127.0.0.1:8080", + Username = "username", + Password = "password", + }; + + using var handler = PulseHttpClientFactory.CreateHandler(proxy); + + await Assert.That(handler.UseProxy).IsTrue(); + await Assert.That(handler.Proxy).IsNotNull(); + await Assert.That(handler.Proxy!.Credentials).IsNotNull(); + var credentials = handler.Proxy!.Credentials as NetworkCredential; + await Assert.That(credentials).IsNotNull(); + await Assert.That(credentials!.UserName).IsEqualTo(proxy.Username); + await Assert.That(credentials.Password).IsEqualTo(proxy.Password); + } +} diff --git a/tests/Pulse.Tests/ParametersTests.cs b/tests/Pulse.Tests/ParametersTests.cs new file mode 100644 index 0000000..4bbf00d --- /dev/null +++ b/tests/Pulse.Tests/ParametersTests.cs @@ -0,0 +1,18 @@ +using Pulse.Models; + +namespace Pulse.Tests; + +public class ParametersTests { + [Test] + public async Task ParametersBase_Default() { + var @params = new ParametersBase(); + + await Assert.That(@params.Requests).IsEqualTo(1); + await Assert.That(@params.Connections).IsEqualTo(1); + await Assert.That(@params.FormatJson).IsFalse(); + await Assert.That(@params.UseFullEquality).IsFalse(); + await Assert.That(@params.Export).IsTrue(); + await Assert.That(@params.NoOp).IsFalse(); + await Assert.That(@params.Verbose).IsFalse(); + } +} diff --git a/tests/Pulse.Tests/Pulse.Tests.csproj b/tests/Pulse.Tests/Pulse.Tests.csproj new file mode 100644 index 0000000..130b898 --- /dev/null +++ b/tests/Pulse.Tests/Pulse.Tests.csproj @@ -0,0 +1,24 @@ + + + + enable + enable + Exe + net10.0 + true + true + true + true + true + + + + + + + + + + + + diff --git a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs b/tests/Pulse.Tests/PulseMonitorTests.cs similarity index 80% rename from tests/Pulse.Tests.Unit/PulseMonitorTests.cs rename to tests/Pulse.Tests/PulseMonitorTests.cs index 19de2e4..4e33ec7 100644 --- a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -1,12 +1,11 @@ using Pulse.Core; using Pulse.Models; -namespace Pulse.Tests.Unit; +namespace Pulse.Tests; public class PulseMonitorTests { - [Fact] + [Test] public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { - // Arrange var requestDetails = new RequestDetails { Proxy = new Proxy(), Request = new Request { @@ -17,9 +16,8 @@ public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, 50); - // Act + Assert var context = new IPulseMonitor.RequestExecutionContext(); var result = await context.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); - Assert.Equal(nameof(TimeoutException), result.Exception.Type); + await Assert.That(result.Exception.Type).IsEqualTo(nameof(TimeoutException)); } -} \ No newline at end of file +} diff --git a/tests/Pulse.Tests.Unit/ResponseComparerTests.cs b/tests/Pulse.Tests/ResponseComparerTests.cs similarity index 72% rename from tests/Pulse.Tests.Unit/ResponseComparerTests.cs rename to tests/Pulse.Tests/ResponseComparerTests.cs index 1658b68..045f8fa 100644 --- a/tests/Pulse.Tests.Unit/ResponseComparerTests.cs +++ b/tests/Pulse.Tests/ResponseComparerTests.cs @@ -1,14 +1,11 @@ using System.Net; - -using Pulse.Configuration; using Pulse.Models; -namespace Pulse.Tests.Unit; +namespace Pulse.Tests; public class ResponseComparerTests { - [Fact] - public void Equals_NotUsingFullEquality_ComparesByContentLength() { - // Arrange + [Test] + public async Task Equals_NotUsingFullEquality_ComparesByContentLength() { var parameters = new Parameters(new ParametersBase { UseFullEquality = false }, CancellationToken.None); var comparer = new ResponseComparer(parameters); var original = CreateResponse(1, HttpStatusCode.OK, "foo"); @@ -18,14 +15,12 @@ public void Equals_NotUsingFullEquality_ComparesByContentLength() { ContentLength = 3 }; - // Act + Assert - Assert.True(comparer.Equals(original, candidate)); - Assert.Equal(comparer.GetHashCode(original), comparer.GetHashCode(candidate)); + await Assert.That(comparer.Equals(original, candidate)).IsTrue(); + await Assert.That(comparer.GetHashCode(original)).IsEqualTo(comparer.GetHashCode(candidate)); } - [Fact] - public void Equals_NotUsingFullEquality_IdentifiesDifferentLengths() { - // Arrange + [Test] + public async Task Equals_NotUsingFullEquality_IdentifiesDifferentLengths() { var parameters = new Parameters(new ParametersBase { UseFullEquality = false }, CancellationToken.None); var comparer = new ResponseComparer(parameters); var original = CreateResponse(1, HttpStatusCode.OK, "foo"); @@ -35,8 +30,7 @@ public void Equals_NotUsingFullEquality_IdentifiesDifferentLengths() { ContentLength = 6 }; - // Act + Assert - Assert.False(comparer.Equals(original, different)); + await Assert.That(comparer.Equals(original, different)).IsFalse(); } private static Response CreateResponse(int id, HttpStatusCode statusCode, string content) { @@ -51,4 +45,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} \ No newline at end of file +} diff --git a/tests/Pulse.Tests/StrippedExceptionTests.cs b/tests/Pulse.Tests/StrippedExceptionTests.cs new file mode 100644 index 0000000..a9ee01f --- /dev/null +++ b/tests/Pulse.Tests/StrippedExceptionTests.cs @@ -0,0 +1,43 @@ +using Pulse.Models; + +namespace Pulse.Tests; + +public class StrippedExceptionTests { + [Test] + public async Task StrippedException_Default_AllEmpty() { + var exception = StrippedException.Default; + + await Assert.That(exception.IsDefault).IsTrue(); + await Assert.That(exception.Type).IsEqualTo(string.Empty); + await Assert.That(exception.Message).IsEqualTo(string.Empty); + } + + [Test] + public async Task StrippedException_JsonCtor_YieldsDefault() { + var exception = new StrippedException(); + + await Assert.That(exception.IsDefault).IsTrue(); + await Assert.That(exception.Type).IsEqualTo(string.Empty); + await Assert.That(exception.Message).IsEqualTo(string.Empty); + } + + [Test] + public async Task StrippedException_FromException_NullYieldsDefault() { + var exception = StrippedException.FromException(null); + + await Assert.That(exception.IsDefault).IsTrue(); + await Assert.That(exception.Type).IsEqualTo(string.Empty); + await Assert.That(exception.Message).IsEqualTo(string.Empty); + } + + [Test] + public async Task StrippedException_FromException_ExceptionYieldsTypeAndMessage() { + var exception = new Exception("Test"); + + var stripped = StrippedException.FromException(exception); + + await Assert.That(stripped.IsDefault).IsFalse(); + await Assert.That(stripped.Type).IsEqualTo(exception.GetType().Name); + await Assert.That(stripped.Message).IsEqualTo(exception.Message); + } +} diff --git a/tests/Pulse.Tests.Unit/SummaryTests.cs b/tests/Pulse.Tests/SummaryTests.cs similarity index 50% rename from tests/Pulse.Tests.Unit/SummaryTests.cs rename to tests/Pulse.Tests/SummaryTests.cs index b31a903..c7b462e 100644 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ b/tests/Pulse.Tests/SummaryTests.cs @@ -1,70 +1,51 @@ using System.Collections.Concurrent; using System.Net; - -using Pulse.Configuration; using Pulse.Core; using Pulse.Models; -namespace Pulse.Tests.Unit; +namespace Pulse.Tests; public class SummaryTests { - [Fact] - public void Summary_Mean_ReturnsCorrectValue() { - // Arrange + [Test] + public async Task Summary_Mean_ReturnsCorrectValue() { var arr = Enumerable.Range(0, 100).Select(_ => Random.Shared.NextDouble()).ToArray(); var expected = arr.Average(); - // Act var actual = PulseSummary.CalculateMean(arr); - // Assert - Assert.Equal(expected, actual, 0.01); + await Assert.That(Math.Abs(actual - expected)).IsLessThan(0.01); } - [Theory] - [ClassData(typeof(SummaryTestData))] - public void GetSummary_TheoryTests(double[] values, bool removeOutliers, double expectedMin, double expectedMax, double expectedAvg, int expectedRemoved) { - // Act + [Test] + [MethodDataSource(nameof(GetSummaryTestData))] + public async Task GetSummary_TheoryTests(double[] values, bool removeOutliers, double expectedMin, double expectedMax, double expectedAvg, int expectedRemoved) { var summary = PulseSummary.GetSummary(values, removeOutliers); - // Assert - Assert.Equal(expectedMin, summary.Min, 0.01); - Assert.Equal(expectedMax, summary.Max, 0.01); - Assert.Equal(expectedAvg, summary.Mean, 0.01); - Assert.Equal(expectedRemoved, summary.Removed, 0.01); + await Assert.That(Math.Abs(summary.Min - expectedMin)).IsLessThan(0.01); + await Assert.That(Math.Abs(summary.Max - expectedMax)).IsLessThan(0.01); + await Assert.That(Math.Abs(summary.Mean - expectedAvg)).IsLessThan(0.01); + await Assert.That(summary.Removed).IsEqualTo(expectedRemoved); } - private class SummaryTestData : TheoryData { - public SummaryTestData() { - // Test case 1: single element - Add([42], false, 42, 42, 42, 0); - // Test case 2: two elements without filtering - Add([10, 20], false, 10, 20, 15, 0); - // Test case 3: multiple elements without filtering - Add([1, 2, 3, 4, 5], false, 1, 5, 3, 0); - // Test case 4: multiple elements with outliers - Add([1, 2, 3, 4, 100], true, 1, 4, 2.5, 1); - // Test case 5: all elements identical without filtering - Add([5, 5, 5, 5], false, 5, 5, 5, 0); - // Test case 6: all elements identical with filtering - Add([5, 5, 5, 5], true, 5, 5, 5, 2); - // Test case 7: multiple outliers on both ends - Add([-10.0, 0.0, 1.0, 2.0, 3.0, 100.0], true, 0.0, 3.0, 1.5, 2); - // Test case 8: large dataset without outliers - Add(Enumerable.Range(1, 1000).Select(x => (double)x).ToArray(), true, 1.0, 1000.0, 500.5, 0); - // Test case 9: large dataset with outliers - Add(Enumerable.Range(1, 1000).Select(x => (double)x).Union([-1000.0, 2000.0]).ToArray(), true, 1.0, 1000.0, 500.5, 2); - } + public static IEnumerable> GetSummaryTestData() { + yield return () => (new[] {42d}, false, 42d, 42d, 42d, 0); + yield return () => (new[] {10d, 20d}, false, 10d, 20d, 15d, 0); + yield return () => (new[] {1d, 2d, 3d, 4d, 5d}, false, 1d, 5d, 3d, 0); + yield return () => (new[] {1d, 2d, 3d, 4d, 100d}, true, 1d, 4d, 2.5d, 1); + yield return () => (new[] {5d, 5d, 5d, 5d}, false, 5d, 5d, 5d, 0); + yield return () => (new[] {5d, 5d, 5d, 5d}, true, 5d, 5d, 5d, 2); + yield return () => (new[] {-10d, 0d, 1d, 2d, 3d, 100d}, true, 0d, 3d, 1.5d, 2); + yield return () => (Enumerable.Range(1, 1000).Select(x => (double)x).ToArray(), true, 1d, 1000d, 500.5d, 0); + yield return () => (Enumerable.Range(1, 1000).Select(x => (double)x).Union([-1000d, 2000d]).ToArray(), true, 1d, 1000d, 500.5d, 2); } - [Fact] + [Test] public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { - // Arrange var outputFolderName = $"pulse-summary-tests-{Guid.NewGuid():N}"; var parameters = new Parameters(new ParametersBase { Export = true, OutputFolder = outputFolderName - }, TestContext.Current.CancellationToken); + }, CancellationToken.None); var exportDirectory = Path.Join(Directory.GetCurrentDirectory(), outputFolderName); var requestDetails = new RequestDetails { Request = new Request { @@ -75,7 +56,7 @@ public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { var responses = new[] { CreateResponse(1, HttpStatusCode.OK, "alpha"), CreateResponse(2, HttpStatusCode.OK, "beta"), - CreateResponse(3, HttpStatusCode.OK, "beta") // Same length as response 2 -> should deduplicate + CreateResponse(3, HttpStatusCode.OK, "beta") }; var stack = new ConcurrentStack(responses); var pulseResult = new PulseResult { @@ -85,15 +66,13 @@ public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { }; try { - // Act await PulseSummary.SummarizeAsync(parameters, requestDetails, pulseResult); - // Assert var exportedFiles = Directory.Exists(exportDirectory) ? Directory.GetFiles(exportDirectory) : Array.Empty(); - Assert.Equal(2, exportedFiles.Length); + await Assert.That(exportedFiles.Length).IsEqualTo(2); } finally { if (Directory.Exists(exportDirectory)) { Directory.Delete(exportDirectory, recursive: true); @@ -113,4 +92,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} \ No newline at end of file +} diff --git a/tests/Pulse.Tests.Unit/VersionTests.cs b/tests/Pulse.Tests/VersionTests.cs similarity index 51% rename from tests/Pulse.Tests.Unit/VersionTests.cs rename to tests/Pulse.Tests/VersionTests.cs index 62d006f..14c1d03 100644 --- a/tests/Pulse.Tests.Unit/VersionTests.cs +++ b/tests/Pulse.Tests/VersionTests.cs @@ -1,15 +1,13 @@ using Pulse.Core; -namespace Pulse.Tests.Unit; +namespace Pulse.Tests; public class VersionTests { - [Fact] - public void Assembly_Version_Matching() { - // Arrange + [Test] + public async Task Assembly_Version_Matching() { var constantVersion = Version.Parse(Commands.Version); var assemblyVersion = typeof(Program).Assembly.GetName().Version!; - // Assert - Assert.Equal(assemblyVersion, constantVersion); + await Assert.That(constantVersion).IsEqualTo(assemblyVersion); } -} \ No newline at end of file +} From cb23453b24ea18960b262a3328e434e94820b4ca Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 13:53:48 +0200 Subject: [PATCH 083/105] Updated workflows --- .github/workflows/unit-tests.yaml | 35 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index a11edb4..a10b27b 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -5,13 +5,34 @@ on: workflow_dispatch: jobs: - unit-tests-matrix: + unit-tests: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, windows-latest, macos-latest] - uses: dusrdev/actions/.github/workflows/reusable-dotnet-test-mtp.yaml@main - with: - platform: ${{ matrix.platform }} - dotnet-version: 9.0.x - test-project-path: tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj \ No newline at end of file + include: + - os: ubuntu-latest + - os: macos-latest + - os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore + + - name: Publish native AOT test runner + run: | + dotnet publish tests/Pulse.Tests/Pulse.Tests.csproj -c Release --no-restore -o "artifacts" + + - name: Run published tests + shell: bash + run: | + out_dir="tests/Pulse.Tests/artifacts" + exe="$out_dir/Pulse.Tests" + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi + "$exe" From 080409d60447fc90e11987e98e2dd7c95296eb10 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:06:33 +0200 Subject: [PATCH 084/105] Fix unit tests workflow --- .github/workflows/unit-tests.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index a10b27b..1b3dc84 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -22,17 +22,14 @@ jobs: with: dotnet-version: 10.0.x - - name: Restore - run: dotnet restore - - name: Publish native AOT test runner run: | - dotnet publish tests/Pulse.Tests/Pulse.Tests.csproj -c Release --no-restore -o "artifacts" + dotnet publish tests/Pulse.Tests/Pulse.Tests.csproj -c Release -o "artifacts" - name: Run published tests shell: bash run: | out_dir="tests/Pulse.Tests/artifacts" - exe="$out_dir/Pulse.Tests" + exe="$out_dir/" if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi "$exe" From 69ed3d425ee78c3994dd8896307a4253ef35ef00 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:14:05 +0200 Subject: [PATCH 085/105] - --- .github/workflows/unit-tests.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 1b3dc84..4baae7f 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -4,6 +4,10 @@ on: pull_request: workflow_dispatch: +env: + test-project: tests/Pulse.Tests/Pulse.Tests.csproj + test-artifacts: tests/Pulse.Tests/artifacts + jobs: unit-tests: strategy: @@ -24,12 +28,11 @@ jobs: - name: Publish native AOT test runner run: | - dotnet publish tests/Pulse.Tests/Pulse.Tests.csproj -c Release -o "artifacts" + dotnet publish ${{ test-project }} -c Release -o "${{ test-artifacts }}" - name: Run published tests shell: bash run: | - out_dir="tests/Pulse.Tests/artifacts" - exe="$out_dir/" + exe="${{ test-artifacts }}/" if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi "$exe" From fed91e3be615c32e8779456db53bd89335f4e2d6 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:18:12 +0200 Subject: [PATCH 086/105] -- --- .github/workflows/unit-tests.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 4baae7f..3f6f58f 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -5,8 +5,8 @@ on: workflow_dispatch: env: - test-project: tests/Pulse.Tests/Pulse.Tests.csproj - test-artifacts: tests/Pulse.Tests/artifacts + TEST_PROJECT: tests/Pulse.Tests/Pulse.Tests.csproj + TEST_ARTIFACTS: tests/Pulse.Tests/artifacts jobs: unit-tests: @@ -28,11 +28,11 @@ jobs: - name: Publish native AOT test runner run: | - dotnet publish ${{ test-project }} -c Release -o "${{ test-artifacts }}" + dotnet publish ${{ env.TEST_PROJECT }} -c Release -o "${{ env.TEST_ARTIFACTS }}" - name: Run published tests shell: bash run: | - exe="${{ test-artifacts }}/" + exe="${{ env.TEST_ARTIFACTS }}/" if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi "$exe" From e2d9126bec47f747e6a006c97dcc0fdf3b9547a6 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:22:00 +0200 Subject: [PATCH 087/105] --- --- .github/workflows/unit-tests.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 3f6f58f..7ea1ef8 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -6,7 +6,8 @@ on: env: TEST_PROJECT: tests/Pulse.Tests/Pulse.Tests.csproj - TEST_ARTIFACTS: tests/Pulse.Tests/artifacts + TEST_ARTIFACTS: tests/Pulse.Tests/artifacts/ + TEST_RUNNER: tests/Pulse.Tests/artifacts/Pulse.Tests jobs: unit-tests: @@ -33,6 +34,6 @@ jobs: - name: Run published tests shell: bash run: | - exe="${{ env.TEST_ARTIFACTS }}/" + exe="${{ env.TEST_RUNNER }}" if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi "$exe" From ecb6b4f023af79f9c2304d49ca9dde7f55e6a36e Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:30:18 +0200 Subject: [PATCH 088/105] Use quiet in tests to prevent github action failure --- tests/Pulse.Tests/SummaryTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Pulse.Tests/SummaryTests.cs b/tests/Pulse.Tests/SummaryTests.cs index c7b462e..f01158a 100644 --- a/tests/Pulse.Tests/SummaryTests.cs +++ b/tests/Pulse.Tests/SummaryTests.cs @@ -44,7 +44,8 @@ public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { var outputFolderName = $"pulse-summary-tests-{Guid.NewGuid():N}"; var parameters = new Parameters(new ParametersBase { Export = true, - OutputFolder = outputFolderName + OutputFolder = outputFolderName, + Quiet = true }, CancellationToken.None); var exportDirectory = Path.Join(Directory.GetCurrentDirectory(), outputFolderName); var requestDetails = new RequestDetails { From 74b24a872dcd3109ae2d5d9a7978bc50931fcb8d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:42:50 +0200 Subject: [PATCH 089/105] Add info command --- src/Pulse/Core/Commands.cs | 14 ++++++++++++++ src/Pulse/Models/InfoModel.cs | 21 +++++++++++++++++++++ src/Pulse/Models/ModelsJsonContext.cs | 1 + src/Pulse/Program.cs | 1 + 4 files changed, 37 insertions(+) create mode 100644 src/Pulse/Models/InfoModel.cs diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 8b5d75c..26e637b 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -183,6 +183,20 @@ public static async Task GetSample(ConsoleAppContext context, string? direc return 0; } + public static int GetInfo(ConsoleAppContext context) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + var info = new InfoModel { + Author = "David Shnayder", + Version = Version, + License = "MIT", + Repository = "https://github.com/dusrdev/Pulse" + }; + info.Output(options.Format); + return 0; + } + /// /// Prints the configuration. /// diff --git a/src/Pulse/Models/InfoModel.cs b/src/Pulse/Models/InfoModel.cs new file mode 100644 index 0000000..d7fa125 --- /dev/null +++ b/src/Pulse/Models/InfoModel.cs @@ -0,0 +1,21 @@ +using System.Text.Json; + +namespace Pulse.Models; + +internal readonly struct InfoModel : IOutputFormatter { + public required string Author { get; init; } + public required string Version { get; init; } + public required string License { get; init; } + public required string Repository { get; init; } + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.InfoModel); + } + + public void OutputAsPlainText() { + Console.WriteLineInterpolated($"Written by {Green}{Markup.Underline}{Author}{Markup.ResetUnderline}{ConsoleColor.DefaultForeground}."); + Console.WriteLineInterpolated($"Version: {Version}"); + Console.WriteLineInterpolated($"License: {License}"); + Console.WriteLineInterpolated($"Repository: {Markup.Underline}{Repository}{Markup.ResetUnderline}"); + } +} \ No newline at end of file diff --git a/src/Pulse/Models/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs index 7fff322..360a9a5 100644 --- a/src/Pulse/Models/ModelsJsonContext.cs +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -5,6 +5,7 @@ namespace Pulse.Models; +[JsonSerializable(typeof(InfoModel))] [JsonSerializable(typeof(GetSampleModel))] [JsonSerializable(typeof(CheckForUpdatesModel))] [JsonSerializable(typeof(RunConfiguration))] diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 58d3cec..6e09f03 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -20,6 +20,7 @@ app.Add("get-schema", Commands.GetSchema); app.Add("check-for-updates", Commands.CheckForUpdates); app.Add("terms-of-use", Commands.TermsOfUse); +app.Add("info", Commands.GetInfo); var schemaCommand = new CliSchemaCommand(app); From 70e152072efe56ed4ce82e8099b1cc62de345611 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Mon, 24 Nov 2025 14:45:27 +0200 Subject: [PATCH 090/105] Renamed output to print --- src/Pulse/Core/Commands.cs | 18 ++++++++++++------ src/Pulse/Core/GlobalExceptionHandler.cs | 4 ++-- src/Pulse/Core/PulseSummary.cs | 4 ++-- src/Pulse/Models/IOutputFormatter.cs | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 26e637b..2f70a9a 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -122,7 +122,7 @@ public static async Task CheckForUpdates(ConsoleAppContext context, Cancell UpdateRequired = currentVersion < remoteVersion }; - outputModel.Output(options.Format); + outputModel.Print(options.Format); return 0; } @@ -139,7 +139,7 @@ public static int TermsOfUse(ConsoleAppContext context) { throw new InvalidCastException(); } var model = new TermsOfServiceModel(); - model.Output(options.Format); + model.Print(options.Format); return 0; } @@ -179,10 +179,16 @@ public static async Task GetSample(ConsoleAppContext context, string? direc var output = new GetSampleModel { Path = path }; - output.Output(options.Format); + output.Print(options.Format); return 0; } + /// + /// Displays information about this app. + /// + /// + /// + /// public static int GetInfo(ConsoleAppContext context) { if (context.GlobalOptions is not GlobalOptions options) { throw new InvalidCastException(); @@ -193,9 +199,9 @@ public static int GetInfo(ConsoleAppContext context) { License = "MIT", Repository = "https://github.com/dusrdev/Pulse" }; - info.Output(options.Format); + info.Print(options.Format); return 0; - } + } /// /// Prints the configuration. @@ -208,6 +214,6 @@ internal static void PrintConfiguration(Parameters parameters, RequestDetails re RequestDetails = requestDetails }; - configuration.Output(parameters.OutputFormat); + configuration.Print(parameters.OutputFormat); } } \ No newline at end of file diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs index ae354c4..7fd6e75 100644 --- a/src/Pulse/Core/GlobalExceptionHandler.cs +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -25,13 +25,13 @@ public override async Task InvokeAsync(ConsoleAppContext context, CancellationTo if (reportsProgress) { ClearFrom(startLine); } - new StrippedException(nameof(OperationCanceledException), "").Output(options.Format); + new StrippedException(nameof(OperationCanceledException), "").Print(options.Format); Environment.ExitCode = 1; } catch (Exception e) { if (reportsProgress) { ClearFrom(startLine); } - StrippedException.FromException(e).Output(options.Format); + StrippedException.FromException(e).Print(options.Format); Environment.ExitCode = 1; } diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 2c35842..2edad6b 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -97,7 +97,7 @@ public static async ValueTask SummarizeAsync(Parameters parameters, RequestDetai StatusCodeCounts = statusCounter }; - output.Output(parameters.OutputFormat); + output.Print(parameters.OutputFormat); if (parameters.Export) { await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); @@ -141,7 +141,7 @@ internal static async ValueTask SummarizeSingleAsync(Parameters parameters, Requ } }; - output.Output(parameters.OutputFormat); + output.Print(parameters.OutputFormat); if (parameters.Export) { var uniqueRequests = new HashSet(1) { result }; diff --git a/src/Pulse/Models/IOutputFormatter.cs b/src/Pulse/Models/IOutputFormatter.cs index 560e023..6886a75 100644 --- a/src/Pulse/Models/IOutputFormatter.cs +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -12,7 +12,7 @@ internal enum OutputFormat { } internal static class OutputFormatterExtensions { - internal static void Output(this IOutputFormatter value, OutputFormat format) { + internal static void Print(this IOutputFormatter value, OutputFormat format) { switch (format) { case OutputFormat.PlainText: value.OutputAsPlainText(); From a6114cad1ed5fb29877323ea9772752d1395e191 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 25 Nov 2025 09:53:46 +0200 Subject: [PATCH 091/105] Update help after new version of ConsoleAppFramework --- src/Pulse/Core/Commands.cs | 10 +++++----- src/Pulse/Program.cs | 2 +- src/Pulse/Pulse.csproj | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 2f70a9a..6d6d2bf 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -19,10 +19,10 @@ internal static class Commands { /// Pulse - A hyper fast general purpose HTTP request tester /// /// - /// Path to .json request details file [use "get-sample" if you don't have one] + /// Path to .json request details file (use "get-sample" if you don't have one) /// Try to format response content as JSON - /// Export raw results [without wrapping in custom HTML] - /// -f, Use full equality [slower] + /// Export raw results (without wrapping in custom HTML) + /// -f, Use full equality (slower) /// Don't export results /// -v, Display verbose output /// Print selected configuration but don't run @@ -146,7 +146,7 @@ public static int TermsOfUse(ConsoleAppContext context) { /// /// Generate a json schema for a request file. /// - /// -d, Configures in which directory [will default to current] + /// -d, Configures in which directory (will default to current) /// /// public static async Task GetSchema(string? directory = null, CancellationToken ct = default) { @@ -165,7 +165,7 @@ public static async Task GetSchema(string? directory = null, CancellationTo /// Generate sample request file. /// /// - /// -d, Configures in which directory [will default to current] + /// -d, Configures in which directory (will default to current) /// /// public static async Task GetSample(ConsoleAppContext context, string? directory = null, CancellationToken ct = default) { diff --git a/src/Pulse/Program.cs b/src/Pulse/Program.cs index 6e09f03..a2747c0 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -10,7 +10,7 @@ app.UseFilter(); app.ConfigureGlobalOptions((ref builder) => { - var format = builder.AddGlobalOption("--output-format", description: "Output as PlainText|JSON", defaultValue: OutputFormat.PlainText); + var format = builder.AddGlobalOption("--output-format", description: "Select output format", defaultValue: OutputFormat.PlainText); var quiet = builder.AddGlobalOption("--quiet", description: "Suppress progress output on stderr (only fatal errors will be shown).", defaultValue: false); return new GlobalOptions(format, quiet); }); diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index f22e125..002621f 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -29,11 +29,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From c8b6a7af368652b18051d3fce95b1f0ecd78266f Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Wed, 26 Nov 2025 11:21:03 +0200 Subject: [PATCH 092/105] Updated to latest version of ConsoleAppFramework --- src/Pulse/Pulse.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 002621f..8febdf3 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -29,11 +29,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + From 2cf1995abbba0a87d1344d2f6fb0f19246a4754c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Thu, 4 Dec 2025 17:13:15 +0200 Subject: [PATCH 093/105] Update PrettyConsole --- src/Pulse/Core/Helper.cs | 1 - src/Pulse/Pulse.csproj | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index 771401b..1c99f33 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -1,7 +1,6 @@ using System.Net; using System.Numerics; -using Pulse.Configuration; using Pulse.Models; namespace Pulse.Core; diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 8febdf3..70d0b11 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -34,7 +34,7 @@ all - + From 48aa14baf9947f8c77cb82a1e22ee1035769d190 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 10:07:08 +0200 Subject: [PATCH 094/105] Update PrettyConsole --- src/Pulse/Pulse.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index 70d0b11..72b5a51 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -34,7 +34,7 @@ all - + From 23937bfb48533ee5d0a3806d9df79327f8d0f98d Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 10:10:41 +0200 Subject: [PATCH 095/105] Remove verbose option --- src/Pulse/Core/Commands.cs | 3 - src/Pulse/Core/IPulseMonitor.cs | 14 ---- src/Pulse/Core/Pulse.cs | 2 +- src/Pulse/Core/VerbosePulseMonitor.cs | 92 --------------------------- src/Pulse/Models/Parameters.cs | 5 -- src/Pulse/Models/RunConfiguration.cs | 1 - 6 files changed, 1 insertion(+), 116 deletions(-) delete mode 100644 src/Pulse/Core/VerbosePulseMonitor.cs diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 6d6d2bf..9faa65c 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -24,7 +24,6 @@ internal static class Commands { /// Export raw results (without wrapping in custom HTML) /// -f, Use full equality (slower) /// Don't export results - /// -v, Display verbose output /// Print selected configuration but don't run /// -o, Output folder /// -d, Delay in milliseconds between requests @@ -39,7 +38,6 @@ public static async Task Root(ConsoleAppContext context, [Argument] string bool raw, bool fullEquality, bool noExport, - bool verbose, bool noOp, string output = "results", int delay = -1, @@ -63,7 +61,6 @@ public static async Task Root(ConsoleAppContext context, [Argument] string UseFullEquality = fullEquality, Export = !noExport, NoOp = noOp, - Verbose = verbose, OutputFormat = options.Format, Quiet = options.Quiet, OutputFolder = output diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs index 67647b9..3c979c2 100644 --- a/src/Pulse/Core/IPulseMonitor.cs +++ b/src/Pulse/Core/IPulseMonitor.cs @@ -2,7 +2,6 @@ using System.Net; using System.Text; -using Pulse.Configuration; using Pulse.Models; namespace Pulse.Core; @@ -11,19 +10,6 @@ namespace Pulse.Core; /// IPulseMonitor defines the traits for the wrappers that handles display of metrics and cross-thread data collection /// internal interface IPulseMonitor { - /// - /// Creates a new pulse monitor according the verbosity setting - /// - /// - /// - /// - public static IPulseMonitor Create(HttpClient client, Request requestRecipe, Parameters parameters) { - if (parameters.Verbose || parameters.Requests == 1) { - return new VerbosePulseMonitor(client, requestRecipe, parameters); - } - return new PulseMonitor(client, requestRecipe, parameters); - } - /// /// Observe needs to be used instead of the execution delegate /// diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 0dca129..5d1bca3 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -16,7 +16,7 @@ public static async Task RunAsync(Parameters parameters, RequestDetails requestD var cancellationToken = parameters.CancellationToken; - var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); + var monitor = new PulseMonitor(httpClient, requestDetails.Request, parameters); // If connections is not modified it will be set to the number of requests // so that all requests are sent in parallel by default. diff --git a/src/Pulse/Core/VerbosePulseMonitor.cs b/src/Pulse/Core/VerbosePulseMonitor.cs deleted file mode 100644 index 2c35365..0000000 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Net; - -using Pulse.Models; - -using static Pulse.Core.IPulseMonitor; - -namespace Pulse.Core; - -/// -/// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection -/// -internal sealed class VerbosePulseMonitor : IPulseMonitor { - /// - /// Holds the results of all the requests - /// - private readonly ConcurrentStack _results; - - /// - /// Timestamp of the beginning of monitoring - /// - private readonly long _start; - - /// - /// Current number of responses received - /// - private PaddedULong _responses; - - /// - /// Current number of successful responses received - /// - private PaddedULong _successes; - - private readonly bool _saveContent; - private readonly CancellationToken _cancellationToken; - private readonly HttpClient _httpClient; - private readonly Request _requestRecipe; - private readonly RequestExecutionContext _requestExecutionContext; - private readonly bool _reportProgress; - - private readonly Lock _lock = new(); - - /// - /// Creates a new verbose pulse monitor - /// - public VerbosePulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { - _results = new ConcurrentStack(); - _saveContent = parameters.Export; - _cancellationToken = parameters.CancellationToken; - _httpClient = client; - _requestRecipe = requestRecipe; - _requestExecutionContext = new RequestExecutionContext(); - _reportProgress = !parameters.Quiet; - _start = Stopwatch.GetTimestamp(); - } - - /// - public async Task SendAsync(int requestId) { - if (_reportProgress) { - lock (_lock) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}--> {ConsoleColor.Default}Sent request: {Yellow}{requestId}"); - } - } - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); - Interlocked.Increment(ref _responses.Value); - // Increment stats - if (result.StatusCode is HttpStatusCode.OK) { - Interlocked.Increment(ref _successes.Value); - } - if (_reportProgress) { - int status = (int)result.StatusCode; - lock (_lock) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}<-- {ConsoleColor.Default}Received response: {Yellow}{requestId}{ConsoleColor.Default}, status code: {Helper.GetStatusCodeBasedColor(status)}{status}"); - } - } - _results.Push(result); - } - - /// - public Task ClearAndReturnAsync() { - if (_reportProgress) { - Console.NewLine(OutputPipe.Error); - } - - return Task.FromResult(new PulseResult { - Results = _results, - SuccessRate = Math.Round((double)_successes.Value / _responses.Value * 100, 2), - TotalDuration = Stopwatch.GetElapsedTime(_start) - }); - } -} \ No newline at end of file diff --git a/src/Pulse/Models/Parameters.cs b/src/Pulse/Models/Parameters.cs index 365f4f6..3e3eed8 100644 --- a/src/Pulse/Models/Parameters.cs +++ b/src/Pulse/Models/Parameters.cs @@ -49,11 +49,6 @@ internal record ParametersBase { /// public bool NoOp { get; init; } - /// - /// Display verbose output (adds more metrics). - /// - public bool Verbose { get; init; } - /// /// The output format to use. /// diff --git a/src/Pulse/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs index 0488dc1..e8a0729 100644 --- a/src/Pulse/Models/RunConfiguration.cs +++ b/src/Pulse/Models/RunConfiguration.cs @@ -25,7 +25,6 @@ public void OutputAsPlainText() { Console.WriteLineInterpolated($"{property} Format JSON: {value}{Parameters.FormatJson}"); Console.WriteLineInterpolated($"{property} Export Full Equality: {value}{Parameters.UseFullEquality}"); Console.WriteLineInterpolated($"{property} Export: {value}{Parameters.Export}"); - Console.WriteLineInterpolated($"{property} Verbose: {value}{Parameters.Verbose}"); Console.WriteLineInterpolated($"{property} OutputFormat: {value}{Parameters.OutputFormat}"); Console.WriteLineInterpolated($"{property} Quiet: {value}{Parameters.Quiet}"); Console.WriteLineInterpolated($"{property} Output Folder: {value}{Parameters.OutputFolder}"); From 71a9dc4d3ef1aa3c90c2e1d091983dd1d7a5bac3 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 10:13:26 +0200 Subject: [PATCH 096/105] Updated changelog --- History.md | 1 + 1 file changed, 1 insertion(+) diff --git a/History.md b/History.md index 28a52a9..ec4f52b 100644 --- a/History.md +++ b/History.md @@ -16,6 +16,7 @@ - Added `cli-schema` command prints the usage schema for the app in JSON format. - Added `--output-format` parameter that can be used to change the output from the default plain-text to structured JSON. - Added `--quiet` parameter that can be used to suppress writing progress to stderr (which can glitch if agents merge the streams). +- `--verbose` option was removed, the default mode with `--quiet` achieves a similar functionality. - Compilations options were refined to produce a even more purpose fit executable. - Smaller output binary size. - Shorter startup times. From 026c13e55f8d13eecef6002f0df019d7af00143c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 10:43:10 +0200 Subject: [PATCH 097/105] Flatten PulseMonitor --- src/Pulse/Core/IPulseMonitor.cs | 106 ---------------------- src/Pulse/Core/PulseMonitor.cs | 11 ++- src/Pulse/Core/RequestExecutionContext.cs | 90 ++++++++++++++++++ tests/Pulse.Tests/ParametersTests.cs | 1 - tests/Pulse.Tests/PulseMonitorTests.cs | 2 +- 5 files changed, 97 insertions(+), 113 deletions(-) delete mode 100644 src/Pulse/Core/IPulseMonitor.cs create mode 100644 src/Pulse/Core/RequestExecutionContext.cs diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs deleted file mode 100644 index 3c979c2..0000000 --- a/src/Pulse/Core/IPulseMonitor.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Text; - -using Pulse.Models; - -namespace Pulse.Core; - -/// -/// IPulseMonitor defines the traits for the wrappers that handles display of metrics and cross-thread data collection -/// -internal interface IPulseMonitor { - /// - /// Observe needs to be used instead of the execution delegate - /// - /// - Task SendAsync(int requestId); - - /// - /// Run cleanup and return results - /// - Task ClearAndReturnAsync(); - - /// - /// Request execution context - /// - internal sealed class RequestExecutionContext { - private PaddedULong _currentConcurrentConnections; - - /// - /// Sends a request. - /// - /// The request identifier. - /// The recipe used to build the request message. - /// Configured instance. - /// Whether response content should be persisted. - /// Cancellation token for the i/o operation. - /// A describing the outcome. - public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { - HttpStatusCode statusCode = 0; - string content = string.Empty; - long contentLength = 0; - int currentConcurrencyLevel = 0; - StrippedException exception = StrippedException.Default; - var headers = Enumerable.Empty>>(); - using var message = requestRecipe.CreateMessage(); - long start = Stopwatch.GetTimestamp(); - HttpResponseMessage? response = null; - try { - currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); - response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - } catch (TimeoutException ex) { - exception = StrippedException.FromException(ex); - } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { - exception = StrippedException.FromException(timeoutEx); - } finally { - Interlocked.Decrement(ref _currentConcurrentConnections.Value); - } - TimeSpan elapsed = Stopwatch.GetElapsedTime(start); - if (!exception.IsDefault) { - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - - try { - var r = response!; - statusCode = r.StatusCode; - headers = r.Headers; - var length = r.Content.Headers.ContentLength; - if (length.HasValue) { - contentLength = length.Value; - } - if (saveContent) { - content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (contentLength == 0) { - var charSet = r.Content.Headers.ContentType?.CharSet; - var encoding = charSet is null - ? Encoding.UTF8 - : Encoding.GetEncoding(charSet.Trim('"')); - contentLength = encoding.GetByteCount(content.AsSpan()); - } - } - } finally { - response?.Dispose(); - } - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - } -} \ No newline at end of file diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index 4e89dce..bb59b59 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -1,18 +1,18 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Threading.Channels; using Pulse.Models; -using static Pulse.Core.IPulseMonitor; - namespace Pulse.Core; /// /// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// -internal sealed class PulseMonitor : IPulseMonitor { +internal sealed class PulseMonitor { /// /// Holds the results of all the requests /// @@ -99,7 +99,8 @@ public async Task SendAsync(int requestId) { // Increment stats int index = (int)result.StatusCode / 100; - Interlocked.Increment(ref _stats[index].Value); + ref var bucket = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_stats), index); + Interlocked.Increment(ref bucket.Value); // Print metrics if (_reportProgress) { @@ -163,4 +164,4 @@ public async Task ClearAndReturnAsync() { TotalDuration = Stopwatch.GetElapsedTime(_start) }; } -} \ No newline at end of file +} diff --git a/src/Pulse/Core/RequestExecutionContext.cs b/src/Pulse/Core/RequestExecutionContext.cs new file mode 100644 index 0000000..e2683b1 --- /dev/null +++ b/src/Pulse/Core/RequestExecutionContext.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Net; +using System.Text; + +using Pulse.Models; + +namespace Pulse.Core; + +/// +/// Executes individual HTTP requests while tracking concurrency and capturing results. +/// +internal sealed class RequestExecutionContext { + private PaddedULong _currentConcurrentConnections; + + /// + /// Sends a request. + /// + /// The request identifier. + /// The recipe used to build the request message. + /// Configured instance. + /// Whether response content should be persisted. + /// Cancellation token for the i/o operation. + /// A describing the outcome. + public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { + HttpStatusCode statusCode = 0; + string content = string.Empty; + long contentLength = 0; + int currentConcurrencyLevel = 0; + StrippedException exception = StrippedException.Default; + var headers = Enumerable.Empty>>(); + using var message = requestRecipe.CreateMessage(); + long start = Stopwatch.GetTimestamp(); + HttpResponseMessage? response = null; + try { + currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); + response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } catch (TimeoutException ex) { + exception = StrippedException.FromException(ex); + } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { + exception = StrippedException.FromException(timeoutEx); + } finally { + Interlocked.Decrement(ref _currentConcurrentConnections.Value); + } + TimeSpan elapsed = Stopwatch.GetElapsedTime(start); + if (!exception.IsDefault) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + + try { + var r = response!; + statusCode = r.StatusCode; + headers = r.Headers; + var length = r.Content.Headers.ContentLength; + if (length.HasValue) { + contentLength = length.Value; + } + if (saveContent) { + content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (contentLength == 0) { + var charSet = r.Content.Headers.ContentType?.CharSet; + var encoding = charSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(charSet.Trim('"')); + contentLength = encoding.GetByteCount(content.AsSpan()); + } + } + } finally { + response?.Dispose(); + } + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } +} diff --git a/tests/Pulse.Tests/ParametersTests.cs b/tests/Pulse.Tests/ParametersTests.cs index 4bbf00d..1711f05 100644 --- a/tests/Pulse.Tests/ParametersTests.cs +++ b/tests/Pulse.Tests/ParametersTests.cs @@ -13,6 +13,5 @@ public async Task ParametersBase_Default() { await Assert.That(@params.UseFullEquality).IsFalse(); await Assert.That(@params.Export).IsTrue(); await Assert.That(@params.NoOp).IsFalse(); - await Assert.That(@params.Verbose).IsFalse(); } } diff --git a/tests/Pulse.Tests/PulseMonitorTests.cs b/tests/Pulse.Tests/PulseMonitorTests.cs index 4e33ec7..180aff2 100644 --- a/tests/Pulse.Tests/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -16,7 +16,7 @@ public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, 50); - var context = new IPulseMonitor.RequestExecutionContext(); + var context = new RequestExecutionContext(); var result = await context.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); await Assert.That(result.Exception.Type).IsEqualTo(nameof(TimeoutException)); } From 3e7df415dc4178406e618abe407b40cfffd549fc Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 18:14:10 +0200 Subject: [PATCH 098/105] Removed requestExecutionContext --- src/Pulse/Core/PulseMonitor.cs | 85 ++++++++++++++++++++- src/Pulse/Core/RequestExecutionContext.cs | 90 ----------------------- 2 files changed, 81 insertions(+), 94 deletions(-) delete mode 100644 src/Pulse/Core/RequestExecutionContext.cs diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs index bb59b59..ea54063 100644 --- a/src/Pulse/Core/PulseMonitor.cs +++ b/src/Pulse/Core/PulseMonitor.cs @@ -1,7 +1,8 @@ using System.Collections.Concurrent; using System.Diagnostics; -using System.Runtime.CompilerServices; +using System.Net; using System.Runtime.InteropServices; +using System.Text; using System.Threading.Channels; using Pulse.Models; @@ -36,7 +37,6 @@ internal sealed class PulseMonitor { // 4: 4xx // 5: 5xx private readonly PaddedULong[] _stats = new PaddedULong[6]; - private readonly RequestExecutionContext _requestExecutionContext; private readonly ulong _requestCount; private readonly bool _saveContent; private readonly CancellationToken _cancellationToken; @@ -57,7 +57,6 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet _cancellationToken = parameters.CancellationToken; _httpClient = client; _requestRecipe = requestRecipe; - _requestExecutionContext = new RequestExecutionContext(); _reportProgress = !parameters.Quiet; _start = Stopwatch.GetTimestamp(); @@ -94,7 +93,7 @@ public PulseMonitor(HttpClient client, Request requestRecipe, Parameters paramet /// public async Task SendAsync(int requestId) { - var result = await _requestExecutionContext.SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); + var result = await SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); Interlocked.Increment(ref _responses.Value); // Increment stats @@ -139,6 +138,84 @@ private static void PrintMetrics(Stats stats) { }, 3); } + private PaddedULong _currentConcurrentConnections; + + /// + /// Sends a request. + /// + /// The request identifier. + /// The recipe used to build the request message. + /// Configured instance. + /// Whether response content should be persisted. + /// Cancellation token for the i/o operation. + /// A describing the outcome. + public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { + HttpStatusCode statusCode = 0; + string content = string.Empty; + long contentLength = 0; + int currentConcurrencyLevel = 0; + StrippedException exception = StrippedException.Default; + var headers = Enumerable.Empty>>(); + using var message = requestRecipe.CreateMessage(); + long start = Stopwatch.GetTimestamp(); + HttpResponseMessage? response = null; + try { + currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); + response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } catch (TimeoutException ex) { + exception = StrippedException.FromException(ex); + } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { + exception = StrippedException.FromException(timeoutEx); + } finally { + Interlocked.Decrement(ref _currentConcurrentConnections.Value); + } + TimeSpan elapsed = Stopwatch.GetElapsedTime(start); + if (!exception.IsDefault) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + + try { + var r = response!; + statusCode = r.StatusCode; + headers = r.Headers; + var length = r.Content.Headers.ContentLength; + if (length.HasValue) { + contentLength = length.Value; + } + if (saveContent) { + content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (contentLength == 0) { + var charSet = r.Content.Headers.ContentType?.CharSet; + var encoding = charSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(charSet.Trim('"')); + contentLength = encoding.GetByteCount(content.AsSpan()); + } + } + } finally { + response?.Dispose(); + } + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + private readonly struct Stats { public required PaddedULong CurrentCount { get; init; } public required PaddedULong[] StatusCodes { get; init; } diff --git a/src/Pulse/Core/RequestExecutionContext.cs b/src/Pulse/Core/RequestExecutionContext.cs deleted file mode 100644 index e2683b1..0000000 --- a/src/Pulse/Core/RequestExecutionContext.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Text; - -using Pulse.Models; - -namespace Pulse.Core; - -/// -/// Executes individual HTTP requests while tracking concurrency and capturing results. -/// -internal sealed class RequestExecutionContext { - private PaddedULong _currentConcurrentConnections; - - /// - /// Sends a request. - /// - /// The request identifier. - /// The recipe used to build the request message. - /// Configured instance. - /// Whether response content should be persisted. - /// Cancellation token for the i/o operation. - /// A describing the outcome. - public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { - HttpStatusCode statusCode = 0; - string content = string.Empty; - long contentLength = 0; - int currentConcurrencyLevel = 0; - StrippedException exception = StrippedException.Default; - var headers = Enumerable.Empty>>(); - using var message = requestRecipe.CreateMessage(); - long start = Stopwatch.GetTimestamp(); - HttpResponseMessage? response = null; - try { - currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); - response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - } catch (TimeoutException ex) { - exception = StrippedException.FromException(ex); - } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { - exception = StrippedException.FromException(timeoutEx); - } finally { - Interlocked.Decrement(ref _currentConcurrentConnections.Value); - } - TimeSpan elapsed = Stopwatch.GetElapsedTime(start); - if (!exception.IsDefault) { - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - - try { - var r = response!; - statusCode = r.StatusCode; - headers = r.Headers; - var length = r.Content.Headers.ContentLength; - if (length.HasValue) { - contentLength = length.Value; - } - if (saveContent) { - content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (contentLength == 0) { - var charSet = r.Content.Headers.ContentType?.CharSet; - var encoding = charSet is null - ? Encoding.UTF8 - : Encoding.GetEncoding(charSet.Trim('"')); - contentLength = encoding.GetByteCount(content.AsSpan()); - } - } - } finally { - response?.Dispose(); - } - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } -} From 520cbfe2f6239fd63329b57fb14a849c85bdc5ad Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 18:19:36 +0200 Subject: [PATCH 099/105] Update tests --- tests/Pulse.Tests/PulseMonitorTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Pulse.Tests/PulseMonitorTests.cs b/tests/Pulse.Tests/PulseMonitorTests.cs index 180aff2..177f0f8 100644 --- a/tests/Pulse.Tests/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -15,9 +15,14 @@ public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { }; using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, 50); + var parameters = new Parameters(new ParametersBase { + Requests = 1, + Export = false, + Quiet = true + }, CancellationToken.None); + var monitor = new PulseMonitor(httpClient, requestDetails.Request, parameters); - var context = new RequestExecutionContext(); - var result = await context.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); + var result = await monitor.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); await Assert.That(result.Exception.Type).IsEqualTo(nameof(TimeoutException)); } } From 2c3b8f6dc16970ef45af6dbefb2ef50f51f6ea32 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 19:11:46 +0200 Subject: [PATCH 100/105] Flatten pulse more --- src/Pulse/Core/Pulse.RunAsync.cs | 49 +++++ src/Pulse/Core/Pulse.cs | 267 +++++++++++++++++++++---- src/Pulse/Core/PulseMonitor.cs | 244 ---------------------- tests/Pulse.Tests/PulseMonitorTests.cs | 2 +- 4 files changed, 279 insertions(+), 283 deletions(-) create mode 100644 src/Pulse/Core/Pulse.RunAsync.cs delete mode 100644 src/Pulse/Core/PulseMonitor.cs diff --git a/src/Pulse/Core/Pulse.RunAsync.cs b/src/Pulse/Core/Pulse.RunAsync.cs new file mode 100644 index 0000000..1004432 --- /dev/null +++ b/src/Pulse/Core/Pulse.RunAsync.cs @@ -0,0 +1,49 @@ +using Pulse.Models; + +namespace Pulse.Core; + +internal sealed partial class Pulse { + /// + /// Runs the pulse according the specification requested in + /// + /// + /// + public static async Task RunAsync(Parameters parameters, RequestDetails requestDetails) { + using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); + + var cancellationToken = parameters.CancellationToken; + + var monitor = new Pulse(httpClient, requestDetails.Request, parameters); + + // If connections is not modified it will be set to the number of requests + // so that all requests are sent in parallel by default. + int concurrencyLevel = Math.Max(1, parameters.Connections); + int totalRequests = parameters.Requests; + int workerCount = totalRequests == 0 ? 0 : Math.Min(concurrencyLevel, totalRequests); + + var workers = new Task[workerCount]; + int nextRequestId = 0; + + for (int i = 0; i < workers.Length; i++) { + workers[i] = Task.Run(async () => { + while (!cancellationToken.IsCancellationRequested) { + int requestId = Interlocked.Increment(ref nextRequestId); + if (requestId > totalRequests) { + break; + } + + await monitor.SendAsync(requestId).ConfigureAwait(false); + if (parameters.DelayInMs > 0) { + await Task.Delay(parameters.DelayInMs, cancellationToken).ConfigureAwait(false); + } + } + }, cancellationToken); + } + + await Task.WhenAll(workers).ConfigureAwait(false); + + var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); + + await PulseSummary.SummarizeAsync(parameters, requestDetails, result).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 5d1bca3..5bb95d6 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -1,52 +1,243 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Channels; + using Pulse.Models; namespace Pulse.Core; /// -/// Pulse runner +/// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection /// -internal static class Pulse { +internal sealed partial class Pulse { + /// + /// Holds the results of all the requests + /// + private readonly ConcurrentStack _results; + + /// + /// Timestamp of the beginning of monitoring + /// + private readonly long _start; + + /// + /// Current number of responses received + /// + private PaddedULong _responses; + + // response status code counter + // 0: exception + // 1: 1xx + // 2: 2xx + // 3: 3xx + // 4: 4xx + // 5: 5xx + private readonly PaddedULong[] _stats = new PaddedULong[6]; + private readonly ulong _requestCount; + private readonly bool _saveContent; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _httpClient; + private readonly Request _requestRecipe; + private readonly Task _printer; + private readonly bool _reportProgress; + + private readonly Channel _channel; + /// - /// Runs the pulse according the specification requested in + /// Creates a new pulse monitor /// - /// - /// - public static async Task RunAsync(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); - - var cancellationToken = parameters.CancellationToken; - - var monitor = new PulseMonitor(httpClient, requestDetails.Request, parameters); - - // If connections is not modified it will be set to the number of requests - // so that all requests are sent in parallel by default. - int concurrencyLevel = Math.Max(1, parameters.Connections); - int totalRequests = parameters.Requests; - int workerCount = totalRequests == 0 ? 0 : Math.Min(concurrencyLevel, totalRequests); - - var workers = new Task[workerCount]; - int nextRequestId = 0; - - for (int i = 0; i < workers.Length; i++) { - workers[i] = Task.Run(async () => { - while (!cancellationToken.IsCancellationRequested) { - int requestId = Interlocked.Increment(ref nextRequestId); - if (requestId > totalRequests) { - break; - } - - await monitor.SendAsync(requestId).ConfigureAwait(false); - if (parameters.DelayInMs > 0) { - await Task.Delay(parameters.DelayInMs, cancellationToken).ConfigureAwait(false); - } + internal Pulse(HttpClient client, Request requestRecipe, Parameters parameters) { + _results = new ConcurrentStack(); + _requestCount = (ulong)parameters.Requests; + _saveContent = parameters.Export; + _cancellationToken = parameters.CancellationToken; + _httpClient = client; + _requestRecipe = requestRecipe; + _reportProgress = !parameters.Quiet; + _start = Stopwatch.GetTimestamp(); + + if (_reportProgress) { + _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { + SingleWriter = false, + SingleReader = true, + FullMode = BoundedChannelFullMode.DropWrite + }); + + _ = _channel.Writer.TryWrite(new Stats { + Percentage = 0, + CurrentCount = _responses, + SuccessRate = 0, + Eta = TimeSpan.MaxValue, + RequestCount = _requestCount, + StatusCodes = _stats + }); + + Console.CursorVisible = false; + ConsoleState.ReportLinesFromCurrent(3); + + _printer = Task.Run(async () => { + await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { + PrintMetrics(stats); } - }, cancellationToken); + }); + } else { + _channel = null!; + + _printer = Task.CompletedTask; } + } + + /// + public async Task SendAsync(int requestId) { + var result = await SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _responses.Value); + // Increment stats + + int index = (int)result.StatusCode / 100; + ref var bucket = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_stats), index); + Interlocked.Increment(ref bucket.Value); + // Print metrics - await Task.WhenAll(workers).ConfigureAwait(false); + if (_reportProgress) { + await PushMetricsAsync().ConfigureAwait(false); + } + _results.Push(result); + } - var result = await monitor.ClearAndReturnAsync().ConfigureAwait(false); + /// + /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. + /// + private async ValueTask PushMetricsAsync() { + var percentage = Helper.Percentage(_responses.Value, _requestCount); + var eta = Helper.GetEta(percentage, Stopwatch.GetElapsedTime(_start)); + double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); + + var stats = new Stats { + Percentage = percentage, + CurrentCount = _responses, + RequestCount = _requestCount, + StatusCodes = _stats, + Eta = eta, + SuccessRate = sr + }; + + await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); + } + + private static void PrintMetrics(Stats stats) { + Console.Overwrite(stats, static s => { + Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); + ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green, maxLineWidth: 34); + Console.NewLine(OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); + }, 3); + } + + private PaddedULong _currentConcurrentConnections; + + /// + /// Sends a request. + /// + /// The request identifier. + /// The recipe used to build the request message. + /// Configured instance. + /// Whether response content should be persisted. + /// Cancellation token for the i/o operation. + /// A describing the outcome. + public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { + HttpStatusCode statusCode = 0; + string content = string.Empty; + long contentLength = 0; + int currentConcurrencyLevel = 0; + StrippedException exception = StrippedException.Default; + var headers = Enumerable.Empty>>(); + using var message = requestRecipe.CreateMessage(); + long start = Stopwatch.GetTimestamp(); + HttpResponseMessage? response = null; + try { + currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); + response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + } catch (TimeoutException ex) { + exception = StrippedException.FromException(ex); + } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { + exception = StrippedException.FromException(timeoutEx); + } finally { + Interlocked.Decrement(ref _currentConcurrentConnections.Value); + } + TimeSpan elapsed = Stopwatch.GetElapsedTime(start); + if (!exception.IsDefault) { + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + + try { + var r = response!; + statusCode = r.StatusCode; + headers = r.Headers; + var length = r.Content.Headers.ContentLength; + if (length.HasValue) { + contentLength = length.Value; + } + if (saveContent) { + content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + if (contentLength == 0) { + var charSet = r.Content.Headers.ContentType?.CharSet; + var encoding = charSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(charSet.Trim('"')); + contentLength = encoding.GetByteCount(content.AsSpan()); + } + } + } finally { + response?.Dispose(); + } + return new Response { + Id = id, + StatusCode = statusCode, + Headers = headers, + Content = content, + ContentLength = contentLength, + Latency = elapsed, + Exception = exception, + CurrentConcurrentConnections = currentConcurrencyLevel + }; + } + + private readonly struct Stats { + public required PaddedULong CurrentCount { get; init; } + public required PaddedULong[] StatusCodes { get; init; } + public required double Percentage { get; init; } + public required TimeSpan Eta { get; init; } + public required double SuccessRate { get; init; } + public required ulong RequestCount { get; init; } + } + + /// + public async Task ClearAndReturnAsync() { + if (_reportProgress) { + // Clear after metrics + _channel.Writer.Complete(); + await _printer.ConfigureAwait(false); + Console.ClearNextLines(3); + Console.CursorVisible = true; + } - await PulseSummary.SummarizeAsync(parameters, requestDetails, result).ConfigureAwait(false); + return new PulseResult { + Results = _results, + SuccessRate = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2), + TotalDuration = Stopwatch.GetElapsedTime(_start) + }; } -} \ No newline at end of file +} diff --git a/src/Pulse/Core/PulseMonitor.cs b/src/Pulse/Core/PulseMonitor.cs deleted file mode 100644 index ea54063..0000000 --- a/src/Pulse/Core/PulseMonitor.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using System.Net; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading.Channels; - -using Pulse.Models; - -namespace Pulse.Core; - - -/// -/// PulseMonitor wraps the execution delegate and handles display of metrics and cross-thread data collection -/// -internal sealed class PulseMonitor { - /// - /// Holds the results of all the requests - /// - private readonly ConcurrentStack _results; - - /// - /// Timestamp of the beginning of monitoring - /// - private readonly long _start; - - /// - /// Current number of responses received - /// - private PaddedULong _responses; - - // response status code counter - // 0: exception - // 1: 1xx - // 2: 2xx - // 3: 3xx - // 4: 4xx - // 5: 5xx - private readonly PaddedULong[] _stats = new PaddedULong[6]; - private readonly ulong _requestCount; - private readonly bool _saveContent; - private readonly CancellationToken _cancellationToken; - private readonly HttpClient _httpClient; - private readonly Request _requestRecipe; - private readonly Task _printer; - private readonly bool _reportProgress; - - private readonly Channel _channel; - - /// - /// Creates a new pulse monitor - /// - public PulseMonitor(HttpClient client, Request requestRecipe, Parameters parameters) { - _results = new ConcurrentStack(); - _requestCount = (ulong)parameters.Requests; - _saveContent = parameters.Export; - _cancellationToken = parameters.CancellationToken; - _httpClient = client; - _requestRecipe = requestRecipe; - _reportProgress = !parameters.Quiet; - _start = Stopwatch.GetTimestamp(); - - if (_reportProgress) { - _channel = Channel.CreateBounded(new BoundedChannelOptions(1) { - SingleWriter = false, - SingleReader = true, - FullMode = BoundedChannelFullMode.DropWrite - }); - - _ = _channel.Writer.TryWrite(new Stats { - Percentage = 0, - CurrentCount = _responses, - SuccessRate = 0, - Eta = TimeSpan.MaxValue, - RequestCount = _requestCount, - StatusCodes = _stats - }); - - Console.CursorVisible = false; - ConsoleState.ReportLinesFromCurrent(3); - - _printer = Task.Run(async () => { - await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { - PrintMetrics(stats); - } - }); - } else { - _channel = null!; - - _printer = Task.CompletedTask; - } - } - - /// - public async Task SendAsync(int requestId) { - var result = await SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); - Interlocked.Increment(ref _responses.Value); - // Increment stats - - int index = (int)result.StatusCode / 100; - ref var bucket = ref Unsafe.Add(ref MemoryMarshal.GetArrayDataReference(_stats), index); - Interlocked.Increment(ref bucket.Value); - // Print metrics - - if (_reportProgress) { - await PushMetricsAsync().ConfigureAwait(false); - } - _results.Push(result); - } - - /// - /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. - /// - private async ValueTask PushMetricsAsync() { - var percentage = Helper.Percentage(_responses.Value, _requestCount); - var eta = Helper.GetEta(percentage, Stopwatch.GetElapsedTime(_start)); - double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); - - var stats = new Stats { - Percentage = percentage, - CurrentCount = _responses, - RequestCount = _requestCount, - StatusCodes = _stats, - Eta = eta, - SuccessRate = sr - }; - - await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); - } - - private static void PrintMetrics(Stats stats) { - Console.Overwrite(stats, static s => { - Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); - ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green, maxLineWidth: 34); - Console.NewLine(OutputPipe.Error); - Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); - Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); - }, 3); - } - - private PaddedULong _currentConcurrentConnections; - - /// - /// Sends a request. - /// - /// The request identifier. - /// The recipe used to build the request message. - /// Configured instance. - /// Whether response content should be persisted. - /// Cancellation token for the i/o operation. - /// A describing the outcome. - public async Task SendRequest(int id, Request requestRecipe, HttpClient httpClient, bool saveContent, CancellationToken cancellationToken) { - HttpStatusCode statusCode = 0; - string content = string.Empty; - long contentLength = 0; - int currentConcurrencyLevel = 0; - StrippedException exception = StrippedException.Default; - var headers = Enumerable.Empty>>(); - using var message = requestRecipe.CreateMessage(); - long start = Stopwatch.GetTimestamp(); - HttpResponseMessage? response = null; - try { - currentConcurrencyLevel = (int)Interlocked.Increment(ref _currentConcurrentConnections.Value); - response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - } catch (TimeoutException ex) { - exception = StrippedException.FromException(ex); - } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested && ex.InnerException is TimeoutException timeoutEx) { - exception = StrippedException.FromException(timeoutEx); - } finally { - Interlocked.Decrement(ref _currentConcurrentConnections.Value); - } - TimeSpan elapsed = Stopwatch.GetElapsedTime(start); - if (!exception.IsDefault) { - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - - try { - var r = response!; - statusCode = r.StatusCode; - headers = r.Headers; - var length = r.Content.Headers.ContentLength; - if (length.HasValue) { - contentLength = length.Value; - } - if (saveContent) { - content = await r.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - if (contentLength == 0) { - var charSet = r.Content.Headers.ContentType?.CharSet; - var encoding = charSet is null - ? Encoding.UTF8 - : Encoding.GetEncoding(charSet.Trim('"')); - contentLength = encoding.GetByteCount(content.AsSpan()); - } - } - } finally { - response?.Dispose(); - } - return new Response { - Id = id, - StatusCode = statusCode, - Headers = headers, - Content = content, - ContentLength = contentLength, - Latency = elapsed, - Exception = exception, - CurrentConcurrentConnections = currentConcurrencyLevel - }; - } - - private readonly struct Stats { - public required PaddedULong CurrentCount { get; init; } - public required PaddedULong[] StatusCodes { get; init; } - public required double Percentage { get; init; } - public required TimeSpan Eta { get; init; } - public required double SuccessRate { get; init; } - public required ulong RequestCount { get; init; } - } - - /// - public async Task ClearAndReturnAsync() { - if (_reportProgress) { - // Clear after metrics - _channel.Writer.Complete(); - await _printer.ConfigureAwait(false); - Console.ClearNextLines(3); - Console.CursorVisible = true; - } - - return new PulseResult { - Results = _results, - SuccessRate = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2), - TotalDuration = Stopwatch.GetElapsedTime(_start) - }; - } -} diff --git a/tests/Pulse.Tests/PulseMonitorTests.cs b/tests/Pulse.Tests/PulseMonitorTests.cs index 177f0f8..6685519 100644 --- a/tests/Pulse.Tests/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -20,7 +20,7 @@ public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { Export = false, Quiet = true }, CancellationToken.None); - var monitor = new PulseMonitor(httpClient, requestDetails.Request, parameters); + var monitor = new Core.Pulse(httpClient, requestDetails.Request, parameters); var result = await monitor.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); await Assert.That(result.Exception.Type).IsEqualTo(nameof(TimeoutException)); From 639f5395cf899b18dc66823014175a335794d8e6 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 19:40:53 +0200 Subject: [PATCH 101/105] Better output formatting --- src/Pulse/Core/Helper.cs | 6 +++--- src/Pulse/Core/Pulse.cs | 23 +++++++++++++---------- src/Pulse/Core/PulseSummary.cs | 4 ++-- src/Pulse/Models/SummaryModel.cs | 12 ++++++------ 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/Pulse/Core/Helper.cs b/src/Pulse/Core/Helper.cs index 1c99f33..7da18e1 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -89,11 +89,11 @@ public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy p /// /// public static void PrintException(this StrippedException e) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Exception type: {ConsoleColor.Default}{e.Type}"); - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Message: {ConsoleColor.Default}{e.Message}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Exception type: {ConsoleColor.DefaultForeground}{e.Type}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Message: {ConsoleColor.DefaultForeground}{e.Message}"); if (e.Detail is not null) { - Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Detail: {ConsoleColor.Default}{e.Detail}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Detail: {ConsoleColor.DefaultForeground}{e.Detail}"); } if (e.InnerException is null or { IsDefault: true }) { diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 5bb95d6..2b46847 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -43,8 +43,8 @@ internal sealed partial class Pulse { private readonly Request _requestRecipe; private readonly Task _printer; private readonly bool _reportProgress; - private readonly Channel _channel; + private volatile int _spinnerIndex; /// /// Creates a new pulse monitor @@ -72,7 +72,8 @@ internal Pulse(HttpClient client, Request requestRecipe, Parameters parameters) SuccessRate = 0, Eta = TimeSpan.MaxValue, RequestCount = _requestCount, - StatusCodes = _stats + StatusCodes = _stats, + SpinnerIndex = _spinnerIndex }); Console.CursorVisible = false; @@ -113,15 +114,17 @@ public async Task SendAsync(int requestId) { private async ValueTask PushMetricsAsync() { var percentage = Helper.Percentage(_responses.Value, _requestCount); var eta = Helper.GetEta(percentage, Stopwatch.GetElapsedTime(_start)); - double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2); + double sr = Math.Round((double)_stats[2].Value / _responses.Value * 100, 2, MidpointRounding.AwayFromZero); + Interlocked.Exchange(ref _spinnerIndex, (_spinnerIndex + 1) % IndeterminateProgressBar.Patterns.Braille.Count); var stats = new Stats { - Percentage = percentage, + Percentage = 100 * percentage, CurrentCount = _responses, RequestCount = _requestCount, StatusCodes = _stats, Eta = eta, - SuccessRate = sr + SuccessRate = sr, + SpinnerIndex = _spinnerIndex }; await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); @@ -129,11 +132,10 @@ private async ValueTask PushMetricsAsync() { private static void PrintMetrics(Stats stats) { Console.Overwrite(stats, static s => { - Console.WriteInterpolated(OutputPipe.Error, $"Completed: {Yellow}{s.CurrentCount.Value}{ConsoleColor.Default}/{Yellow}{s.RequestCount}{ConsoleColor.Default} "); - ProgressBar.WriteProgressBar(OutputPipe.Error, s.Percentage * 100, Green, maxLineWidth: 34); - Console.NewLine(OutputPipe.Error); - Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.Default}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); - Console.WriteLineInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.Default}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.Default}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.Default}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.Default}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.Default}, others: {Magenta}{s.StatusCodes[0].Value}"); + var spinner = IndeterminateProgressBar.Patterns.Braille; + Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}{spinner[s.SpinnerIndex]}{ConsoleColor.DefaultForeground} Completed: {Magenta}{s.Percentage,6:#.##}%{ConsoleColor.DefaultForeground}, Requests: {Yellow}{s.CurrentCount.Value}{ConsoleColor.DefaultForeground}/{Yellow}{s.RequestCount}{ConsoleColor.DefaultForeground}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.DefaultForeground}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); + Console.WriteInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.DefaultForeground}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.DefaultForeground}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.DefaultForeground}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.DefaultForeground}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.DefaultForeground}, others: {Magenta}{s.StatusCodes[0].Value}"); }, 3); } @@ -222,6 +224,7 @@ private readonly struct Stats { public required TimeSpan Eta { get; init; } public required double SuccessRate { get; init; } public required ulong RequestCount { get; init; } + public required int SpinnerIndex { get; init; } } /// diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 2edad6b..2d541c5 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -279,7 +279,7 @@ internal static async Task ExportUniqueRequestsAsync(Parameters parameters, Hash if (count is 1) { await Exporter.ExportResponseAsync(uniqueRequests.First(), directory, parameters, parameters.CancellationToken).ConfigureAwait(false); - Console.WriteLineInterpolated($"{Green}1{ConsoleColor.Default} unique response exported to {Yellow}{directory}"); + Console.WriteLineInterpolated($"{Green}1{ConsoleColor.DefaultForeground} unique response exported to {Yellow}{directory}"); return; } @@ -290,6 +290,6 @@ internal static async Task ExportUniqueRequestsAsync(Parameters parameters, Hash await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn).ConfigureAwait(false)).ConfigureAwait(false); - Console.WriteLineInterpolated($"{Green}{count}{ConsoleColor.Default} unique responses exported to {Yellow}{directory}{ConsoleColor.Default}"); + Console.WriteLineInterpolated($"{Green}{count}{ConsoleColor.DefaultForeground} unique responses exported to {Yellow}{directory}{ConsoleColor.DefaultForeground}"); } } \ No newline at end of file diff --git a/src/Pulse/Models/SummaryModel.cs b/src/Pulse/Models/SummaryModel.cs index b62a050..f84688a 100644 --- a/src/Pulse/Models/SummaryModel.cs +++ b/src/Pulse/Models/SummaryModel.cs @@ -22,24 +22,24 @@ public void OutputAsJson() { } public void OutputAsPlainText() { - Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(Target.HttpMethod)}{Target.HttpMethod}{ConsoleColor.Default} => {Markup.Underline}{Target.Url}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"{Helper.GetMethodBasedColor(Target.HttpMethod)}{Target.HttpMethod}{ConsoleColor.DefaultForeground} => {Markup.Underline}{Target.Url}{Markup.ResetUnderline}"); Console.WriteLineInterpolated($"Request count: {Yellow}{RequestCount}"); Console.WriteLineInterpolated($"Concurrent connections: {Yellow}{ConcurrentConnections}"); Console.WriteLineInterpolated($"Total duration: {Yellow}{TotalDuration:duration}"); Console.WriteLineInterpolated($"Success Rate: {Helper.GetPercentageBasedColor(SuccessRate)}{SuccessRate}%"); - Console.WriteLineInterpolated($"Latency: Min: {Green}{LatencyInMilliseconds.Min:0.##}ms{ConsoleColor.Default}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.Default}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); + Console.WriteLineInterpolated($"Latency: Min: {Green}{LatencyInMilliseconds.Min:0.##}ms{ConsoleColor.DefaultForeground}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.DefaultForeground}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); if (LatencyOutliersRemoved != 0) { - Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.Default} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); + Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.DefaultForeground} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); } - Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSizeInBytes.Min:bytes}{ConsoleColor.Default}, Mean: {Yellow}{ContentSizeInBytes.Mean:bytes}{ConsoleColor.Default}, Max: {Red}{ContentSizeInBytes.Max:bytes}"); + Console.WriteLineInterpolated($"Content Size: Min: {Green}{ContentSizeInBytes.Min:bytes}{ConsoleColor.DefaultForeground}, Mean: {Yellow}{ContentSizeInBytes.Mean:bytes}{ConsoleColor.DefaultForeground}, Max: {Red}{ContentSizeInBytes.Max:bytes}"); Console.WriteLineInterpolated($"Total throughput: {Yellow}{ThroughputBytesPerSecond:bytes}/s"); Console.WriteLineInterpolated($"Status codes:"); foreach (var kvp in StatusCodeCounts.OrderBy(static s => (int)s.Key)) { var key = (int)kvp.Key; if (key is 0) { - Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.Default} --> {kvp.Value} [StatusCode 0 = Exception]"); + Console.WriteLineInterpolated($" {Magenta}{key}{ConsoleColor.DefaultForeground} --> {kvp.Value} [StatusCode 0 = Exception]"); } else { - Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.Default} --> {kvp.Value}"); + Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.DefaultForeground} --> {kvp.Value}"); } } Console.NewLine(); From 32549b7fae558fde9df88a122acc6dff5a77bfd7 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 19:56:42 +0200 Subject: [PATCH 102/105] Update formatting of dashboard --- assets/pulse-running.png | Bin 54394 -> 47580 bytes src/Pulse/Core/Pulse.cs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/pulse-running.png b/assets/pulse-running.png index 7285be1d9ebcdd46ec0d5346b920aeee97646de7..b01ea405e2c92acc0fe294b3444019a562852ff1 100644 GIT binary patch literal 47580 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 R+i<{-tfb<{a&e=e{{=6*MNR+! literal 54394 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 z%0A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 diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 2b46847..330e665 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -133,7 +133,7 @@ private async ValueTask PushMetricsAsync() { private static void PrintMetrics(Stats stats) { Console.Overwrite(stats, static s => { var spinner = IndeterminateProgressBar.Patterns.Braille; - Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}{spinner[s.SpinnerIndex]}{ConsoleColor.DefaultForeground} Completed: {Magenta}{s.Percentage,6:#.##}%{ConsoleColor.DefaultForeground}, Requests: {Yellow}{s.CurrentCount.Value}{ConsoleColor.DefaultForeground}/{Yellow}{s.RequestCount}{ConsoleColor.DefaultForeground}"); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}{spinner[s.SpinnerIndex]}{ConsoleColor.DefaultForeground} Completed: {Cyan}{s.Percentage,6:#.##}%{ConsoleColor.DefaultForeground}, Requests: {Yellow}{s.CurrentCount.Value}{ConsoleColor.DefaultForeground}/{Yellow}{s.RequestCount}{ConsoleColor.DefaultForeground}"); Console.WriteLineInterpolated(OutputPipe.Error, $"Success Rate: {Helper.GetPercentageBasedColor(s.SuccessRate)}{s.SuccessRate}{ConsoleColor.DefaultForeground}%, Estimated time remaining: {Yellow}{s.Eta:duration}"); Console.WriteInterpolated(OutputPipe.Error, $"1xx: {White}{s.StatusCodes[1].Value}{ConsoleColor.DefaultForeground}, 2xx: {Green}{s.StatusCodes[2].Value}{ConsoleColor.DefaultForeground}, 3xx: {Yellow}{s.StatusCodes[3].Value}{ConsoleColor.DefaultForeground}, 4xx: {Red}{s.StatusCodes[4].Value}{ConsoleColor.DefaultForeground}, 5xx: {Red}{s.StatusCodes[5].Value}{ConsoleColor.DefaultForeground}, others: {Magenta}{s.StatusCodes[0].Value}"); }, 3); From 7e7f314691201f56a9101ddc08c3587eb0b943b4 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 20:12:13 +0200 Subject: [PATCH 103/105] Update docs --- Agents.md | 68 ++++++++++----------- Readme.md | 36 +++++------ src/Pulse/Configuration/InputJsonContext.cs | 5 -- src/Pulse/Core/Commands.cs | 4 ++ 4 files changed, 56 insertions(+), 57 deletions(-) diff --git a/Agents.md b/Agents.md index 1abfb85..3af1df7 100644 --- a/Agents.md +++ b/Agents.md @@ -3,54 +3,53 @@ ## Overview - Pulse is a ConsoleAppFramework-driven CLI that stress-tests HTTP endpoints using JSON-defined request recipes. - The codebase is organized into small, purpose-built agents that coordinatedly parse configuration, execute requests, collect telemetry, and export results. -- This document captures the role, location, and interactions of every agent discovered during the repository scan performed on November 3, 2025. +- This document captures the role, location, and interactions of every agent discovered during the repository scan performed on December 7, 2025. ## Runtime Agents ### Command & Orchestration Agent - **Files:** `src/Pulse/Program.cs`, `src/Pulse/Core/Commands.cs` -- Registers the command surface (`Pulse`, `get-sample`, `get-schema`, `update`, `terms-of-use`) and wires a global exception filter. +- Registers the command surface (`Pulse`, `get-sample`, `get-schema`, `check-for-updates`, `terms-of-use`, `info`, `cli-schema`) and wires a global exception filter. - Adds global options: - `--output-format` (PlainText|JSON) to select human-readable vs structured output for all commands. - `--quiet` to silence progress reporting on stderr while still allowing fatal errors. -- `Commands.Root` parses CLI inputs into `ParametersBase`, loads request definitions from disk via `InputJsonContext`, and triggers the execution pipeline. -- Hosts helper commands that generate request samples and JSON Schema artifacts, print terms of use, and query GitHub releases for updates. +- `Commands.Root` parses CLI inputs into `ParametersBase`, loads request definitions from disk via `InputJsonContext`, and triggers the execution pipeline. When `--connections` is omitted, it is set to the request count so runs default to fully parallel. +- Helper commands generate request samples and JSON Schema artifacts, print terms of use, surface app metadata (`info`), and query GitHub releases for updates. ### Output Formatting Agent - **Files:** `src/Pulse/Models/IOutputFormatter.cs`, `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Core/Helper.cs`, `src/Pulse/Models/*Model.cs` - `IOutputFormatter` defines paired `OutputAsPlainText` / `OutputAsJson` methods with an `Output(OutputFormat)` extension to centralize human-vs-LLM rendering. - `OutputFormat` (PlainText/JSON) is provided by the global `--output-format` option and stored in `GlobalOptions` for all commands. -- User-facing models (`RunConfiguration`, `SummaryModel`, `TermsOfServiceModel`, `CheckForUpdatesModel`, `GetSampleModel`) implement the interface so the same execution flow can emit colored console text or serialized JSON. +- User-facing models (`RunConfiguration`, `SummaryModel`, `TermsOfServiceModel`, `CheckForUpdatesModel`, `GetSampleModel`, `InfoModel`) implement the interface so the same execution flow can emit colored console text or serialized JSON. ### Quiet/Progress Agent -- **Files:** `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Models/Parameters.cs`, `src/Pulse/Core/PulseMonitor.cs`, `src/Pulse/Core/VerbosePulseMonitor.cs`, `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/GlobalExceptionHandler.cs` -- The global `--quiet` option flows into `Parameters.Quiet` to suppress progress dashboards and per-request logs on stderr while keeping fatal/error reporting intact. -- Progress printers in both monitors and cross-referencing logs in `PulseSummary` honor this flag; the exception handler clears progress regions only when they were rendered. +- **Files:** `src/Pulse/Models/GlobalOptions.cs`, `src/Pulse/Models/Parameters.cs`, `src/Pulse/Core/Pulse.cs`, `src/Pulse/Core/ConsoleState.cs`, `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/GlobalExceptionHandler.cs` +- The global `--quiet` option flows into `Parameters.Quiet`; when false, `Pulse` streams Stats over a bounded channel to render a spinner, progress percentage, success rate, ETA, and status buckets on stderr while tracking cursor positions via `ConsoleState`. +- When `--quiet` is true the channel and printer are skipped, but results are still collected; `GlobalExceptionHandler` only clears console regions when progress was rendered. ### Configuration Agent -- **Files:** `src/Pulse/Configuration/InputJsonContext.cs`, `src/Pulse/Configuration/DefaultJsonContext.cs`, `src/Pulse/Configuration/Parameters.cs`, `src/Pulse/Core/RequestDetails.cs` +- **Files:** `src/Pulse/Configuration/InputJsonContext.cs`, `src/Pulse/Configuration/DefaultJsonContext.cs`, `src/Pulse/Models/Parameters.cs`, `src/Pulse/Models/RequestDetails.cs` - Source generators (`JsonSerializerContext`) provide strongly typed serializers for request payloads, headers, exceptions, and release metadata. -- `Parameters` (and its base) capture run-time tuning knobs (request count, timeout, concurrency, export flags, verbosity, output directory, cancellation token). -- `RequestDetails`, `Request`, `Proxy`, and `Content` model the input JSON, supply request factories, and compute byte-size estimates. +- `Parameters` capture run-time tuning knobs (request count, timeout, concurrency, export flags, output directory, cancellation token). +- `RequestDetails`, `Request`, `Proxy`, and `Content` model the input JSON, supply request factories, and compute byte-size estimates via `Request.GetRequestLength` for throughput reporting. ### HTTP Execution Agent -- **Files:** `src/Pulse/Core/Pulse.cs`, `src/Pulse/Core/PulseHttpClientFactory.cs`, `src/Pulse/Core/IPulseMonitor.cs` -- `Pulse.RunAsync` builds an `HttpClient` (proxy-aware via `PulseHttpClientFactory`), instantiates a monitor, and dispatches multiple tasks gated by a `SemaphoreSlim`. -- `IPulseMonitor.RequestExecutionContext` prepares `HttpRequestMessage` instances, executes them with streamed responses, tracks concurrency, and normalizes results into `Response`. -- Proxy handling honors bypass flags, credentials, and optional SSL certificate suppression (`Helper.ConfigureSslHandling` extension). +- **Files:** `src/Pulse/Core/Pulse.RunAsync.cs`, `src/Pulse/Core/Pulse.cs`, `src/Pulse/Core/PulseHttpClientFactory.cs` +- `Pulse.RunAsync` builds an `HttpClient` (proxy-aware via `PulseHttpClientFactory`), instantiates the `Pulse` runner, and spins up `min(connections, requests)` worker tasks; if `--connections` is omitted it defaults to the request count so all requests fire in parallel. +- Each worker loops until all request ids are consumed, optionally delaying between sends; `Pulse.SendRequest` constructs the `HttpRequestMessage`, tracks current concurrency, streams responses, and normalizes results into `Response` records that include `CurrentConcurrentConnections`. +- Proxy handling honors bypass flags, credentials, and optional SSL certificate suppression (`Helper.ConfigureSslHandling`). -### Monitoring Agents -- **Files:** `src/Pulse/Core/PulseMonitor.cs`, `src/Pulse/Core/VerbosePulseMonitor.cs`, `src/Pulse/Core/PaddedULong.cs` -- `PulseMonitor` provides dashboard-style aggregation: concurrent stacks store `Response` data while padded counters avoid false sharing when sampling metrics. -- `VerbosePulseMonitor` favors per-request logging (send/receive) with success tracking when verbose mode or single-shot execution is selected. -- Both implementations ultimately flush results through `ClearAndReturn`, returning a `PulseResult` with success rates and total duration. +### Progress & Metrics Agent +- **Files:** `src/Pulse/Core/Pulse.cs`, `src/Pulse/Core/ConsoleState.cs`, `src/Pulse/Models/PaddedULong.cs` +- A single `Pulse` implementation owns progress sampling: it aggregates status buckets into a padded counter array to avoid contention, pushes periodic `Stats` snapshots over a bounded channel, and computes success rate/ETA/spinner frames for on-screen dashboards. +- `ConcurrentStack` stores results for later summarization; `ClearAndReturnAsync` shuts down the printer, restores cursor visibility, and returns a `PulseResult` containing total duration and aggregate success rate. ### Result Aggregation & Export Agents -- **Files:** `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/Exporter.cs`, `src/Pulse/Core/Response.cs`, `src/Pulse/Core/RawFailure.cs` -- `PulseSummary` deduplicates responses (`ResponseComparer`), tallies status buckets, computes latency/size statistics (with IQR filtering and SIMD acceleration), and orchestrates exports. +- **Files:** `src/Pulse/Core/PulseSummary.cs`, `src/Pulse/Core/Exporter.cs`, `src/Pulse/Models/Response.cs`, `src/Pulse/Models/RawFailure.cs` +- `PulseSummary` tallies status buckets, computes latency/size statistics (with IQR filtering and SIMD acceleration), captures peak concurrent connections from `Response.CurrentConcurrentConnections`, and deduplicates responses via `ResponseComparer` when exports are requested. +- Throughput now factors outbound request size and, when `Export` is enabled, includes response payload bytes; `SummaryModel` exposes the computed `ConcurrentConnections`, throughput, and outlier removal counts. - `Exporter` emits either prettified HTML dashboards or raw JSON/HTML blobs, formats headers into tables, and can purge previous result folders. - `RawFailure` offers a compact representation of unsuccessful HTTP interactions for raw exports. -- Throughput, ETA, and success-rate colorization rely on utilities in `Helper.cs`. ### Error Handling & Diagnostic Agent - **Files:** `src/Pulse/Core/ExceptionHandler.cs`, `src/Pulse/Configuration/StrippedException.cs`, `src/Pulse/Core/Helper.cs` @@ -60,7 +59,7 @@ ### Release & Version Agent - **Files:** `src/Pulse/Core/ReleaseInfo.cs`, `src/Pulse/Core/Commands.cs` -- Maintains the CLI semantic version (`Commands.VERSION`) and validates it against assembly metadata (reinforced by unit tests). +- Maintains the CLI semantic version (`Commands.Version`) and validates it against assembly metadata (reinforced by unit tests and the `info` command output). - Parses GitHub release payloads (`ReleaseInfo`) to notify users about upgrades. ### Shared Infrastructure Agent @@ -70,23 +69,24 @@ - Formatting guidelines and tooling preferences are driven by `.editorconfig`. ## Testing Agents -- **Project:** `tests/Pulse.Tests.Unit` +- **Project:** `tests/Pulse.Tests` - `ExporterTests.cs` validates clearing behaviors, file naming, header serialization, JSON formatting, HTML generation, and exception rendering across raw and HTML exports. - `HelperTests.cs` asserts color-selection logic for status codes and success percentages. -- `HttpClientFactoryTests.cs` (and related fixtures) ensure handler composition respects proxy settings and SSL overrides. +- `HttpClientFactoryTests.cs` ensure handler composition respects proxy settings and SSL overrides. - `ParametersTests.cs` document expected defaults for request counts, connections, and export toggles. -- `PulseMonitorTests.cs` exercise timeout handling via `RequestExecutionContext`. +- `PulseMonitorTests.cs` validate timeout handling in `Pulse.SendRequest`. +- `ResponseComparerTests.cs` verify deduplication semantics with and without full content equality. - `StrippedExceptionTests.cs` confirm serialization contracts and null handling. - `SummaryTests.cs` cover statistical reductions, outlier trimming, and SIMD-friendly averaging. -- `VersionTests.cs` keep `Commands.VERSION` synchronized with assembly metadata. +- `VersionTests.cs` keep `Commands.Version` synchronized with assembly metadata. ## Data Flow Snapshot -1. User invokes the CLI; global options set `GlobalOptions.OutputFormat` and `Quiet`, then `Commands.Root` loads `RequestDetails` (and optional overrides) into `Parameters`. -2. `Pulse.RunAsync` creates proxy-aware HTTP plumbing, chooses a monitor (verbose or dashboard), and schedules the requested workload with semaphore-throttled concurrency. -3. `RequestExecutionContext` issues HTTP requests, captures responses or exceptions, and records latency and concurrency metrics inside `Response`. -4. Monitors update live console feedback (unless `Quiet`) and accumulate results before handing off a `PulseResult`. -5. `PulseSummary` materializes `SummaryModel` and other `IOutputFormatter` models, printing either plaintext or JSON, then deduplicates responses and instructs `Exporter` to persist unique payloads (raw or HTML) into the configured output folder; summary progress lines are suppressed when `Quiet`. -6. `GlobalExceptionHandler` guarantees graceful shutdown, while optional commands (`get-sample`, `get-schema`, `update`, `terms-of-use`) reuse serialization agents for auxiliary workflows. +1. User invokes the CLI; global options set `GlobalOptions.OutputFormat` and `Quiet`, then `Commands.Root` loads `RequestDetails` (and optional URL overrides) into `Parameters`, defaulting `Connections` to the request count when not provided. +2. `Pulse.RunAsync` creates proxy-aware HTTP plumbing, instantiates the `Pulse` runner, and spins up worker tasks capped by `Connections`. +3. Workers request ids incrementally, call `Pulse.SendRequest`, which constructs and sends `HttpRequestMessage` instances, tracks current concurrency, and pushes progress stats unless `Quiet`. +4. `Pulse.ClearAndReturnAsync` shuts down progress rendering and yields a `PulseResult` containing the success rate and elapsed duration. +5. `PulseSummary` materializes `SummaryModel` (plaintext or JSON), computes peak concurrency, latency/size summaries with optional outlier removal, and instructs `Exporter` to persist deduplicated responses (raw or HTML) when exports are enabled. +6. `GlobalExceptionHandler` guarantees graceful shutdown, while auxiliary commands (`get-sample`, `get-schema`, `cli-schema`, `check-for-updates`, `terms-of-use`, `info`) reuse serialization agents for their workflows. ## External Dependencies - **ConsoleAppFramework** (CLI host) and **PrettyConsole** (colored terminal output). diff --git a/Readme.md b/Readme.md index 7496534..8cf7f28 100644 --- a/Readme.md +++ b/Readme.md @@ -15,6 +15,7 @@ Pulse is a general purpose, cross-platform, performance-oriented, command-line u - Format JSON outputs - Captures all response headers for debugging - Quiet mode to silence progress noise when piping or scripting +- Reports peak concurrent connections and throughput in the summary output And more! @@ -38,7 +39,7 @@ During the execution, `Pulse` displays current metrics such as progress, success ![Running](assets/pulse-running.png) -After the execution (different configuration in this example), `Pulse` produces a detailed summary of the results +After the execution (different configuration in this example), `Pulse` produces a detailed summary of the results, including the peak concurrent connections reached and overall throughput. ![Summary](assets/pulse-summary.png) @@ -114,29 +115,29 @@ Usage: [command] [arguments...] [options...] [-h|--help] [--version] Pulse - A hyper fast general purpose HTTP request tester Arguments: - [0] Path to .json request details file [use "get-sample" if you don't have one] + [0] Path to .json request details file (use "get-sample" if you don't have one) Options: - --json Try to format response content as JSON (Optional) - --raw Export raw results [without wrapping in custom HTML] (Optional) - --output-format Output as PlainText|JSON (Default: PlainText) - --quiet Suppress progress output on stderr (only fatal errors will be shown) (Default: False) - -f, --full-equality Use full equality [slower] (Optional) - --no-export Don't export results (Optional) - -v, --verbose Display verbose output (Optional) - --no-op Print selected configuration but don't run (Optional) - -o, --output Output folder (Default: @"results") - -d, --delay Delay in milliseconds between requests (Default: -1) - -c, --connections Maximum number of parallel requests (Default: null) - -u, --url Override the url of the request (Default: null) - -n, --number Number of total requests (Default: 1) - -t, --timeout Timeout in milliseconds (Default: -1) + --json Try to format response content as JSON + --raw Export raw results (without wrapping in custom HTML) + -f, --full-equality Use full equality (slower) + --no-export Don't export results + --no-op Print selected configuration but don't run + -o, --output Output folder [Default: @"results"] + -d, --delay Delay in milliseconds between requests [Default: -1] + -c, --connections Maximum number of parallel requests [Default: null] + -u, --url Override the url of the request [Default: null] + -n, --number Number of total requests [Default: 1] + -t, --timeout Timeout in milliseconds [Default: -1] + --output-format Select output format [Default: PlainText] + --quiet Suppress progress output on stderr (only fatal errors will be shown). Commands: check-for-updates Checks whether there is a new version out on GitHub releases. cli-schema Returns the usage schema for the app in JSON format. get-sample Generate sample request file. get-schema Generate a json schema for a request file. + info Displays information about this app. terms-of-use Print the terms of use. ``` @@ -145,9 +146,8 @@ Commands: - `--output-format PlainText|JSON` (global) - choose human-readable console output or structured JSON for automation/LLMs. - `--quiet` (global) - suppress progress updates on stderr; only fatal errors remain. Useful when piping to `jq` or when stderr/stdout are merged. - `-f|--full-equality` - enforce full response equality checks instead of length-based comparisons. -- `-v|--verbose` - display per-request logging instead of the dashboard UI. - `--no-op` - print the parsed configuration without running any requests. -- `-c|--connections` - cap parallel requests; set to `1` for sequential execution or leave unset to match the total request count. +- `-c|--connections` - cap parallel requests; set to `1` for sequential execution. When omitted, it defaults to the request count (`--number`). - `-d|--delay` - add a delay (ms) after each request completes; useful when `--connections` is `1`. - `-u|--url` - override the request URL while keeping the rest of the configuration unchanged. - `-o|--output` - choose a custom output directory (defaults to `results`). diff --git a/src/Pulse/Configuration/InputJsonContext.cs b/src/Pulse/Configuration/InputJsonContext.cs index 2b85ec3..f0b61e4 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -26,11 +26,6 @@ internal partial class InputJsonContext : JsonSerializerContext { /// [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool TryGetRequestDetailsFromFile(string path, out RequestDetails details) { - if (!File.Exists(path)) { - details = null!; - return false; - } - var json = File.ReadAllText(path); var rd = JsonSerializer.Deserialize(json, Default.RequestDetails); diff --git a/src/Pulse/Core/Commands.cs b/src/Pulse/Core/Commands.cs index 9faa65c..2dbdfb7 100644 --- a/src/Pulse/Core/Commands.cs +++ b/src/Pulse/Core/Commands.cs @@ -67,6 +67,10 @@ public static async Task Root(ConsoleAppContext context, [Argument] string }; var requestFilePath = Path.GetFullPath(requestFile); + if (!File.Exists(requestFilePath)) { + Console.WriteLineInterpolated(OutputPipe.Error, $"Request file does not exist at {Markup.Underline}{Yellow}{requestFilePath}{Markup.ResetUnderline}"); + return 1; + } if (!InputJsonContext.TryGetRequestDetailsFromFile(requestFilePath, out RequestDetails requestDetails)) { Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to retrieve and parse request file from {Markup.Underline}{Yellow}{requestFilePath}{Markup.ResetUnderline}"); From 1b89c2d701e6933e00d20b3fb016ca20985b47cd Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 20:13:11 +0200 Subject: [PATCH 104/105] Update dependencies --- profiling/runner.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profiling/runner.cs b/profiling/runner.cs index 34f50d2..804473f 100644 --- a/profiling/runner.cs +++ b/profiling/runner.cs @@ -1,5 +1,5 @@ -#:package PrettyConsole@5.1.0 -#:package ConsoleAppFramework@5.7.11 +#:package PrettyConsole@5.3.0 +#:package ConsoleAppFramework@5.7.13 #:package CliWrap@3.10.0 using CliWrap; From 6c8240a3d04ac92ae73996771d7d37e47195ead9 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Sun, 7 Dec 2025 20:13:33 +0200 Subject: [PATCH 105/105] Formatting --- src/Pulse/Core/Pulse.cs | 2 +- src/Pulse/Models/InfoModel.cs | 18 +++++++++--------- tests/Pulse.Tests/ExporterTests.cs | 2 +- tests/Pulse.Tests/HelperTests.cs | 2 +- tests/Pulse.Tests/HttpClientFactoryTests.cs | 2 +- tests/Pulse.Tests/ParametersTests.cs | 2 +- tests/Pulse.Tests/PulseMonitorTests.cs | 2 +- tests/Pulse.Tests/ResponseComparerTests.cs | 3 ++- tests/Pulse.Tests/StrippedExceptionTests.cs | 2 +- tests/Pulse.Tests/SummaryTests.cs | 17 +++++++++-------- tests/Pulse.Tests/VersionTests.cs | 2 +- 11 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/Pulse/Core/Pulse.cs b/src/Pulse/Core/Pulse.cs index 330e665..8440af7 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -243,4 +243,4 @@ public async Task ClearAndReturnAsync() { TotalDuration = Stopwatch.GetElapsedTime(_start) }; } -} +} \ No newline at end of file diff --git a/src/Pulse/Models/InfoModel.cs b/src/Pulse/Models/InfoModel.cs index d7fa125..213e9ee 100644 --- a/src/Pulse/Models/InfoModel.cs +++ b/src/Pulse/Models/InfoModel.cs @@ -3,19 +3,19 @@ namespace Pulse.Models; internal readonly struct InfoModel : IOutputFormatter { - public required string Author { get; init; } - public required string Version { get; init; } - public required string License { get; init; } - public required string Repository { get; init; } + public required string Author { get; init; } + public required string Version { get; init; } + public required string License { get; init; } + public required string Repository { get; init; } public void OutputAsJson() { - JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.InfoModel); + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.InfoModel); } public void OutputAsPlainText() { - Console.WriteLineInterpolated($"Written by {Green}{Markup.Underline}{Author}{Markup.ResetUnderline}{ConsoleColor.DefaultForeground}."); - Console.WriteLineInterpolated($"Version: {Version}"); - Console.WriteLineInterpolated($"License: {License}"); - Console.WriteLineInterpolated($"Repository: {Markup.Underline}{Repository}{Markup.ResetUnderline}"); + Console.WriteLineInterpolated($"Written by {Green}{Markup.Underline}{Author}{Markup.ResetUnderline}{ConsoleColor.DefaultForeground}."); + Console.WriteLineInterpolated($"Version: {Version}"); + Console.WriteLineInterpolated($"License: {License}"); + Console.WriteLineInterpolated($"Repository: {Markup.Underline}{Repository}{Markup.ResetUnderline}"); } } \ No newline at end of file diff --git a/tests/Pulse.Tests/ExporterTests.cs b/tests/Pulse.Tests/ExporterTests.cs index 7ceb5b6..4bf7709 100644 --- a/tests/Pulse.Tests/ExporterTests.cs +++ b/tests/Pulse.Tests/ExporterTests.cs @@ -387,4 +387,4 @@ public async Task Exporter_ExportHtmlAsync_WithException_HasExceptionAndNoConten dirInfo.Delete(true); } } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/HelperTests.cs b/tests/Pulse.Tests/HelperTests.cs index f76315e..30bcf45 100644 --- a/tests/Pulse.Tests/HelperTests.cs +++ b/tests/Pulse.Tests/HelperTests.cs @@ -31,4 +31,4 @@ public async Task Extensions_GetStatusCodeBasedColor(HttpStatusCode statusCode, await Assert.That(color).IsEqualTo(expected); } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/HttpClientFactoryTests.cs b/tests/Pulse.Tests/HttpClientFactoryTests.cs index dfbbda5..dd89571 100644 --- a/tests/Pulse.Tests/HttpClientFactoryTests.cs +++ b/tests/Pulse.Tests/HttpClientFactoryTests.cs @@ -95,4 +95,4 @@ public async Task CreateHandler_WithProxy_WithCredentials() { await Assert.That(credentials!.UserName).IsEqualTo(proxy.Username); await Assert.That(credentials.Password).IsEqualTo(proxy.Password); } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/ParametersTests.cs b/tests/Pulse.Tests/ParametersTests.cs index 1711f05..81201ae 100644 --- a/tests/Pulse.Tests/ParametersTests.cs +++ b/tests/Pulse.Tests/ParametersTests.cs @@ -14,4 +14,4 @@ public async Task ParametersBase_Default() { await Assert.That(@params.Export).IsTrue(); await Assert.That(@params.NoOp).IsFalse(); } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/PulseMonitorTests.cs b/tests/Pulse.Tests/PulseMonitorTests.cs index 6685519..c1bc13d 100644 --- a/tests/Pulse.Tests/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -25,4 +25,4 @@ public async Task SendAsync_ReturnsTimeoutException_OnTimeout() { var result = await monitor.SendRequest(1, requestDetails.Request, httpClient, false, CancellationToken.None); await Assert.That(result.Exception.Type).IsEqualTo(nameof(TimeoutException)); } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/ResponseComparerTests.cs b/tests/Pulse.Tests/ResponseComparerTests.cs index 045f8fa..b66b45b 100644 --- a/tests/Pulse.Tests/ResponseComparerTests.cs +++ b/tests/Pulse.Tests/ResponseComparerTests.cs @@ -1,4 +1,5 @@ using System.Net; + using Pulse.Models; namespace Pulse.Tests; @@ -45,4 +46,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/StrippedExceptionTests.cs b/tests/Pulse.Tests/StrippedExceptionTests.cs index a9ee01f..aae1115 100644 --- a/tests/Pulse.Tests/StrippedExceptionTests.cs +++ b/tests/Pulse.Tests/StrippedExceptionTests.cs @@ -40,4 +40,4 @@ public async Task StrippedException_FromException_ExceptionYieldsTypeAndMessage( await Assert.That(stripped.Type).IsEqualTo(exception.GetType().Name); await Assert.That(stripped.Message).IsEqualTo(exception.Message); } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/SummaryTests.cs b/tests/Pulse.Tests/SummaryTests.cs index f01158a..8dab7cc 100644 --- a/tests/Pulse.Tests/SummaryTests.cs +++ b/tests/Pulse.Tests/SummaryTests.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net; + using Pulse.Core; using Pulse.Models; @@ -28,13 +29,13 @@ public async Task GetSummary_TheoryTests(double[] values, bool removeOutliers, d } public static IEnumerable> GetSummaryTestData() { - yield return () => (new[] {42d}, false, 42d, 42d, 42d, 0); - yield return () => (new[] {10d, 20d}, false, 10d, 20d, 15d, 0); - yield return () => (new[] {1d, 2d, 3d, 4d, 5d}, false, 1d, 5d, 3d, 0); - yield return () => (new[] {1d, 2d, 3d, 4d, 100d}, true, 1d, 4d, 2.5d, 1); - yield return () => (new[] {5d, 5d, 5d, 5d}, false, 5d, 5d, 5d, 0); - yield return () => (new[] {5d, 5d, 5d, 5d}, true, 5d, 5d, 5d, 2); - yield return () => (new[] {-10d, 0d, 1d, 2d, 3d, 100d}, true, 0d, 3d, 1.5d, 2); + yield return () => (new[] { 42d }, false, 42d, 42d, 42d, 0); + yield return () => (new[] { 10d, 20d }, false, 10d, 20d, 15d, 0); + yield return () => (new[] { 1d, 2d, 3d, 4d, 5d }, false, 1d, 5d, 3d, 0); + yield return () => (new[] { 1d, 2d, 3d, 4d, 100d }, true, 1d, 4d, 2.5d, 1); + yield return () => (new[] { 5d, 5d, 5d, 5d }, false, 5d, 5d, 5d, 0); + yield return () => (new[] { 5d, 5d, 5d, 5d }, true, 5d, 5d, 5d, 2); + yield return () => (new[] { -10d, 0d, 1d, 2d, 3d, 100d }, true, 0d, 3d, 1.5d, 2); yield return () => (Enumerable.Range(1, 1000).Select(x => (double)x).ToArray(), true, 1d, 1000d, 500.5d, 0); yield return () => (Enumerable.Range(1, 1000).Select(x => (double)x).Union([-1000d, 2000d]).ToArray(), true, 1d, 1000d, 500.5d, 2); } @@ -93,4 +94,4 @@ private static Response CreateResponse(int id, HttpStatusCode statusCode, string CurrentConcurrentConnections = 1 }; } -} +} \ No newline at end of file diff --git a/tests/Pulse.Tests/VersionTests.cs b/tests/Pulse.Tests/VersionTests.cs index 14c1d03..72981b3 100644 --- a/tests/Pulse.Tests/VersionTests.cs +++ b/tests/Pulse.Tests/VersionTests.cs @@ -10,4 +10,4 @@ public async Task Assembly_Version_Matching() { await Assert.That(constantVersion).IsEqualTo(assemblyVersion); } -} +} \ No newline at end of file