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/.github/workflows/publish-current-release.yaml b/.github/workflows/publish-current-release.yaml index 143fa32..18f02b1 100644 --- a/.github/workflows/publish-current-release.yaml +++ b/.github/workflows/publish-current-release.yaml @@ -48,11 +48,62 @@ 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 + # - 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: diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index 99ffb2b..7ea1ef8 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -4,46 +4,36 @@ on: pull_request: workflow_dispatch: +env: + TEST_PROJECT: tests/Pulse.Tests/Pulse.Tests.csproj + TEST_ARTIFACTS: tests/Pulse.Tests/artifacts/ + TEST_RUNNER: tests/Pulse.Tests/artifacts/Pulse.Tests + jobs: - test-pulse: - runs-on: ${{ matrix.os }} + unit-tests: 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 - + include: + - os: ubuntu-latest + - os: macos-latest + - os: windows-latest + runs-on: ${{ matrix.os }} 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- + - uses: actions/checkout@v4 - # 3. Setup .NET - - name: Setup .NET + - name: Setup .NET 10 uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - # 4. Clean - - name: Clean + - name: Publish native AOT test runner run: | - dotnet clean ${{ env.PROJECT }} -c ${{ matrix.configuration }} - dotnet clean ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} + dotnet publish ${{ env.TEST_PROJECT }} -c Release -o "${{ env.TEST_ARTIFACTS }}" - # 5. Run Unit Tests - - name: Run Unit Tests - run: dotnet test ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} \ No newline at end of file + - name: Run published tests + shell: bash + run: | + exe="${{ env.TEST_RUNNER }}" + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then exe="$exe.exe"; fi + "$exe" diff --git a/.gitignore b/.gitignore index 29a4773..7063259 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,500 @@ -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 + +# 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/Agents.md b/Agents.md new file mode 100644 index 0000000..3af1df7 --- /dev/null +++ b/Agents.md @@ -0,0 +1,100 @@ +# 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 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`, `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. 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`, `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/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/Models/Parameters.cs`, `src/Pulse/Models/RequestDetails.cs` +- Source generators (`JsonSerializerContext`) provide strongly typed serializers for request payloads, headers, exceptions, and release metadata. +- `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.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`). + +### 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/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. + +### 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 and the `info` command output). +- 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` +- `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` ensure handler composition respects proxy settings and SSL overrides. +- `ParametersTests.cs` document expected defaults for request counts, connections, and export toggles. +- `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. + +## Data Flow Snapshot +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). +- **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. diff --git a/Changelog.md b/Changelog.md deleted file mode 100644 index 978a663..0000000 --- a/Changelog.md +++ /dev/null @@ -1,6 +0,0 @@ -# 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) diff --git a/History.md b/History.md index 1a10ca9..ec4f52b 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,28 @@ # 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. +- 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). +- 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). +- `--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. + - 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 - `--raw` export mode now outputs a special json with debug information for non-successful responses with no content, it contains: 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..d991d63 --- /dev/null +++ b/Pulse.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Readme.md b/Readme.md index 6741f2d..8cf7f28 100644 --- a/Readme.md +++ b/Readme.md @@ -5,14 +5,17 @@ Pulse is a general purpose, cross-platform, performance-oriented, command-line u ## Features - JSON based request configuration -- Support for using proxies -- True multi-threading with configurable modes (max concurrency, batches, sequential) +- 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 +- Structured output toggle (PlainText or JSON) for terminals, scripts, and LLMs - Format JSON outputs -- Capture all response headers for debugging +- 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! @@ -34,17 +37,17 @@ 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 +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](https://github.com/user-attachments/assets/9a05ff6e-8d3d-46af-8509-013b5b1536a0) +![Summary](assets/pulse-summary.png) ### Setting up a configuration file 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 +110,48 @@ 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 + --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. ``` -- `--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`. +- `--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. +- `--no-op` - print the parsed configuration without running any requests. +- `-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`). +- `-n|--number` and `-t|--timeout` - control how many requests run and the per-request timeout (ms). ## Disclaimer diff --git a/assets/pulse-running.png b/assets/pulse-running.png new file mode 100644 index 0000000..b01ea40 Binary files /dev/null and b/assets/pulse-running.png differ diff --git a/assets/pulse-summary.png b/assets/pulse-summary.png new file mode 100644 index 0000000..3e0a1bd Binary files /dev/null and b/assets/pulse-summary.png differ diff --git a/profiling/runner.cs b/profiling/runner.cs new file mode 100644 index 0000000..804473f --- /dev/null +++ b/profiling/runner.cs @@ -0,0 +1,57 @@ +#:package PrettyConsole@5.3.0 +#:package ConsoleAppFramework@5.7.13 +#:package CliWrap@3.10.0 + +using CliWrap; +using CliWrap.Buffered; + +using ConsoleAppFramework; + +using PrettyConsole; + +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; + } + + 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 0; + } +} diff --git a/profiling/server.cs b/profiling/server.cs new file mode 100644 index 0000000..92c6777 --- /dev/null +++ b/profiling/server.cs @@ -0,0 +1,297 @@ +#: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(); + +app.MapGet("/", static () => Results.Ok("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 Results.Ok(payload); +}); + +app.MapGet("/html", static () => { + const string payload = +""" + + + + + + 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, +

+ + +"""; + return Results.Ok(payload); +}); + +app.Run(); \ No newline at end of file diff --git a/src/Pulse/Configuration/DefaultJsonContext.cs b/src/Pulse/Configuration/DefaultJsonContext.cs index c727600..d89b71c 100644 --- a/src/Pulse/Configuration/DefaultJsonContext.cs +++ b/src/Pulse/Configuration/DefaultJsonContext.cs @@ -1,56 +1,52 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Pulse.Core; - -using Sharpify; +using Pulse.Models; 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, + 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)); +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) { + 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..f0b61e4 100644 --- a/src/Pulse/Configuration/InputJsonContext.cs +++ b/src/Pulse/Configuration/InputJsonContext.cs @@ -1,40 +1,40 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Pulse.Core; - -using Sharpify; +using Pulse.Models; 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()); - } +internal partial class InputJsonContext : JsonSerializerContext { + /// + /// 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) { + 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 deleted file mode 100644 index ab37d89..0000000 --- a/src/Pulse/Configuration/Parameters.cs +++ /dev/null @@ -1,105 +0,0 @@ -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"; -} - -/// -/// 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; - } -} \ No newline at end of file diff --git a/src/Pulse/Configuration/StrippedException.cs b/src/Pulse/Configuration/StrippedException.cs deleted file mode 100644 index e72e095..0000000 --- a/src/Pulse/Configuration/StrippedException.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text.Json.Serialization; - -using Pulse.Core; - -namespace Pulse.Configuration; - -/// -/// An exception only containing the type, message and stack trace -/// -public sealed record StrippedException { - public static readonly StrippedException Default = new(); - - /// - /// Type of the exception - /// - public readonly string Type; - - /// - /// Message of the exception - /// - public readonly string Message; - - /// - /// Detail of the exception (if any) - /// - public readonly string? Detail; - - /// - /// 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; - - /// - /// 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 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; - } -} \ No newline at end of file diff --git a/src/Pulse/Core/CliSchemaCommand.cs b/src/Pulse/Core/CliSchemaCommand.cs new file mode 100644 index 0000000..d3e7ea2 --- /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/Core/Commands.cs b/src/Pulse/Core/Commands.cs new file mode 100644 index 0000000..2dbdfb7 --- /dev/null +++ b/src/Pulse/Core/Commands.cs @@ -0,0 +1,220 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Schema; + +using ConsoleAppFramework; + +using Pulse.Configuration; +using Pulse.Models; + +namespace Pulse.Core; + +/// +/// Commands +/// +internal 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 + /// 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(ConsoleAppContext context, [Argument] string requestFile, + bool json, + bool raw, + bool fullEquality, + bool noExport, + 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) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + 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, + OutputFormat = options.Format, + Quiet = options.Quiet, + OutputFolder = output + }; + + 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}"); + return 1; + } + + if (url is not null) { + requestDetails.Request.Url = url; + } + + var @params = new Parameters(parametersBase, ct); + + if (@params.NoOp) { + PrintConfiguration(@params, requestDetails); + return 0; + } + + await Pulse.RunAsync(@params, requestDetails).ConfigureAwait(false); + return 0; + } + + /// + /// Checks whether there is a new version out on GitHub releases. + /// + /// + /// + /// + 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"); + using var message = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/repos/dusrdev/Pulse/releases/latest"); + using var response = await client.SendAsync(message, ct).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { + var json = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!DefaultJsonContext.TryDeserializeVersion(json, out var remoteVersion)) { + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to retrieve version from remote."); + return 1; + } + ArgumentNullException.ThrowIfNull(remoteVersion); + var currentVersion = System.Version.Parse(Version); + + var outputModel = new CheckForUpdatesModel { + CurrentVersion = currentVersion, + RemoteVersion = remoteVersion, + UpdateRequired = currentVersion < remoteVersion + }; + + outputModel.Print(options.Format); + return 0; + } + + Console.WriteLineInterpolated(OutputPipe.Error, $"Failed to check for updates - server response was not success"); + return 1; + } + + /// + /// Print the terms of use. + /// + /// + public static int TermsOfUse(ConsoleAppContext context) { + if (context.GlobalOptions is not GlobalOptions options) { + throw new InvalidCastException(); + } + var model = new TermsOfServiceModel(); + model.Print(options.Format); + return 0; + } + + /// + /// Generate a json schema for a request 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).ConfigureAwait(false); + Console.WriteLineInterpolated($"Schema generated at {Markup.Underline}{Yellow}{path}{Markup.ResetUnderline}"); + return 0; + } + + /// + /// Generate sample request file. + /// + /// + /// -d, Configures in which directory (will default to current) + /// + /// + 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); + var output = new GetSampleModel { + Path = path + }; + 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(); + } + var info = new InfoModel { + Author = "David Shnayder", + Version = Version, + License = "MIT", + Repository = "https://github.com/dusrdev/Pulse" + }; + info.Print(options.Format); + return 0; + } + + /// + /// Prints the configuration. + /// + /// + /// + internal static void PrintConfiguration(Parameters parameters, RequestDetails requestDetails) { + var configuration = new RunConfiguration { + Parameters = parameters, + RequestDetails = requestDetails + }; + + configuration.Print(parameters.OutputFormat); + } +} \ No newline at end of file diff --git a/src/Pulse/Core/ConsoleState.cs b/src/Pulse/Core/ConsoleState.cs new file mode 100644 index 0000000..8a670c5 --- /dev/null +++ b/src/Pulse/Core/ConsoleState.cs @@ -0,0 +1,27 @@ +namespace Pulse.Core; + +internal static class ConsoleState { + public static int LinesWritten { + get; + 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 = Console.GetCurrentLine(); + int lastLine = current + lineCount - 1; + UpdateMax(lastLine); + } + + private static void UpdateMax(int candidate) { + if (LinesWritten >= candidate) return; + LinesWritten = candidate; + } +} \ No newline at end of file diff --git a/src/Pulse/Core/Exporter.cs b/src/Pulse/Core/Exporter.cs index cb207b0..c957224 100644 --- a/src/Pulse/Core/Exporter.cs +++ b/src/Pulse/Core/Exporter.cs @@ -3,117 +3,117 @@ using System.Text.Json; using Pulse.Configuration; - -using Sharpify; +using Pulse.Models; 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; - } +internal static class Exporter { + 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) { +#pragma warning disable CA1031 // Do not catch general exception types + 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; + } +#pragma warning restore CA1031 // Do not catch general exception types + 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).ConfigureAwait(false); } - } - 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.Length == 0 ? + """

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 +277,8 @@ tbody td { } } """; - string body = -$$""" + string body = + $$""" Response: {{result.Id}} @@ -299,62 +299,61 @@ tbody td {
"""; - await File.WriteAllTextAsync(filename, body, token); - } + await File.WriteAllTextAsync(filename, body, token).ConfigureAwait(false); + } - /// - /// 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) { +#pragma warning disable CA1031 // Do not catch general exception types + try { + File.Delete(file); + } catch { + // ignored + } +#pragma warning restore CA1031 // Do not catch general exception types + } } - } } \ No newline at end of file diff --git a/src/Pulse/Core/GlobalExceptionHandler.cs b/src/Pulse/Core/GlobalExceptionHandler.cs new file mode 100644 index 0000000..7fd6e75 --- /dev/null +++ b/src/Pulse/Core/GlobalExceptionHandler.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +using ConsoleAppFramework; + +using Pulse.Models; + +namespace Pulse.Core; + +#pragma warning disable CA1031 // Do not catch general exception types + +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) { + if (reportsProgress) { + ClearFrom(startLine); + } + new StrippedException(nameof(OperationCanceledException), "").Print(options.Format); + Environment.ExitCode = 1; + } catch (Exception e) { + if (reportsProgress) { + ClearFrom(startLine); + } + StrippedException.FromException(e).Print(options.Format); + Environment.ExitCode = 1; + } + + 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); + Console.GoToLine(start); + Console.ClearNextLines(lines, OutputPipe.Out); + Console.GoToLine(start); + } + } +} + +#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 c3946a6..7da18e1 100644 --- a/src/Pulse/Core/Helper.cs +++ b/src/Pulse/Core/Helper.cs @@ -1,29 +1,44 @@ -using static PrettyConsole.Console; -using PrettyConsole; - -using Pulse.Configuration; using System.Net; +using System.Numerics; +using Pulse.Models; namespace Pulse.Core; /// /// Helper class /// -public static class Helper { +internal static class Helper { + public static double Percentage(T current, T total) where T : INumberBase { + return double.CreateChecked(current / total); + } + + 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 a text color based on percentage /// /// /// /// - public static Color GetPercentageBasedColor(double percentage) { + public static ConsoleColor GetPercentageBasedColor(double percentage) { ArgumentOutOfRangeException.ThrowIfGreaterThan((uint)percentage, 100); return percentage switch { - >= 75 => Color.Green, - >= 50 => Color.Yellow, - _ => Color.Red + >= 75 => Green, + >= 50 => Yellow, + _ => Red }; } @@ -32,32 +47,30 @@ public static Color GetPercentageBasedColor(double percentage) { /// /// /// - public static Color GetStatusCodeBasedColor(int statusCode) { + public static ConsoleColor 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 + /// + /// + /// + public static ConsoleColor GetMethodBasedColor(string method) + => method switch { + "GET" => Green, + "DELETE" => Red, + "POST" => Magenta, + _ => Yellow }; - return [request.Method.Method * color, " => ", request.Url]; - } - /// /// Configures SSL handling /// @@ -65,7 +78,9 @@ public static ColoredOutput[] CreateHeader(Request request) { /// public static void ConfigureSslHandling(this SocketsHttpHandler handler, Proxy proxy) { if (proxy.IgnoreSSL) { - handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true; +#pragma warning disable CA5359 // Do Not Disable Certificate Validation + handler.SslOptions.RemoteCertificateValidationCallback = static (_, _, _, _) => true; +#pragma warning restore CA5359 // Do Not Disable Certificate Validation } } @@ -73,23 +88,21 @@ 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); + public static void PrintException(this StrippedException e) { + 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) { - Error.Write(padding); - WriteLine(["Detail" * Color.Yellow, ": ", e.Detail], OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Yellow}Detail: {ConsoleColor.DefaultForeground}{e.Detail}"); } + if (e.InnerException is null or { IsDefault: true }) { return; } - Error.Write(padding); - Error.WriteLine("Inner Exception:"); - PrintException(e.InnerException, indent + 2); + + Console.NewLine(OutputPipe.Error); + Console.WriteLineInterpolated(OutputPipe.Error, $"{Magenta}Inner exception:"); + PrintException(e.InnerException); } /// diff --git a/src/Pulse/Core/IPulseMonitor.cs b/src/Pulse/Core/IPulseMonitor.cs deleted file mode 100644 index 3a634ea..0000000 --- a/src/Pulse/Core/IPulseMonitor.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Diagnostics; -using System.Net; -using System.Text; - -using Pulse.Configuration; - -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); - } - - /// - /// Observe needs to be used instead of the execution delegate - /// - /// - Task SendAsync(int requestId); - - /// - /// Consolidates the results into an object - /// - PulseResult Consolidate(); - - /// - /// 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 - }; - } - - 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 - }; - } - } -} \ No newline at end of file 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 eeff5c5..8440af7 100644 --- a/src/Pulse/Core/Pulse.cs +++ b/src/Pulse/Core/Pulse.cs @@ -1,133 +1,246 @@ -using Pulse.Configuration; +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 /// -public static class Pulse { +internal sealed partial class Pulse { /// - /// Runs the pulse according the specification requested in + /// Holds the results of all the requests /// - /// - /// - 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); - } + private readonly ConcurrentStack _results; /// - /// Runs the pulse sequentially + /// Timestamp of the beginning of monitoring /// - /// - /// - 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); - } - } + private readonly long _start; /// - /// Runs the pulse in parallel batches + /// Current number of responses received /// - /// - /// - internal static async Task RunBounded(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); - - var cancellationToken = parameters.CancellationToken; + 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; + private volatile int _spinnerIndex; - var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); - - using var semaphore = new SemaphoreSlim(parameters.MaxConnections); + /// + /// Creates a new pulse monitor + /// + 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 + }); - var tasks = new Task[parameters.Requests]; + _ = _channel.Writer.TryWrite(new Stats { + Percentage = 0, + CurrentCount = _responses, + SuccessRate = 0, + Eta = TimeSpan.MaxValue, + RequestCount = _requestCount, + StatusCodes = _stats, + SpinnerIndex = _spinnerIndex + }); - for (int i = 0; i < parameters.Requests; i++) { - await semaphore.WaitAsync(cancellationToken); + Console.CursorVisible = false; + ConsoleState.ReportLinesFromCurrent(3); -#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(); + _printer = Task.Run(async () => { + await foreach (var stats in _channel.Reader.ReadAllAsync(_cancellationToken).ConfigureAwait(false)) { + PrintMetrics(stats); + } }); -#pragma warning restore IDE0053 // Use expression body for lambda expression + } else { + _channel = null!; + _printer = Task.CompletedTask; } + } - 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() - }; + /// + public async Task SendAsync(int requestId) { + var result = await SendRequest(requestId, _requestRecipe, _httpClient, _saveContent, _cancellationToken).ConfigureAwait(false); + Interlocked.Increment(ref _responses.Value); + // Increment stats - var (exportRequired, uniqueRequests) = summary.Summarize(); + 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 (exportRequired) { - await summary.ExportUniqueRequestsAsync(uniqueRequests, cancellationToken); + if (_reportProgress) { + await PushMetricsAsync().ConfigureAwait(false); } + _results.Push(result); } /// - /// Runs the pulse in parallel without any batching + /// Handles printing the current metrics, has to be synchronized to prevent cross writing to the console, which produces corrupted output. /// - /// - /// - internal static async Task RunUnbounded(Parameters parameters, RequestDetails requestDetails) { - using var httpClient = PulseHttpClientFactory.Create(requestDetails.Proxy, parameters.TimeoutInMs); + 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, MidpointRounding.AwayFromZero); + Interlocked.Exchange(ref _spinnerIndex, (_spinnerIndex + 1) % IndeterminateProgressBar.Patterns.Braille.Count); + + var stats = new Stats { + Percentage = 100 * percentage, + CurrentCount = _responses, + RequestCount = _requestCount, + StatusCodes = _stats, + Eta = eta, + SuccessRate = sr, + SpinnerIndex = _spinnerIndex + }; - var cancellationToken = parameters.CancellationToken; + await _channel.Writer.WriteAsync(stats, _cancellationToken).ConfigureAwait(false); + } - var monitor = IPulseMonitor.Create(httpClient, requestDetails.Request, parameters); + 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: {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); + } - var tasks = new Task[parameters.Requests]; + private PaddedULong _currentConcurrentConnections; - for (int i = 0; i < parameters.Requests; i++) { - tasks[i] = monitor.SendAsync(i + 1); + /// + /// 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 + }; } - 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() + 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 }; + } - var (exportRequired, uniqueRequests) = summary.Summarize(); + 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 required int SpinnerIndex { get; init; } + } - if (exportRequired) { - await summary.ExportUniqueRequestsAsync(uniqueRequests, cancellationToken); + /// + 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) + }; } } \ No newline at end of file diff --git a/src/Pulse/Core/PulseHttpClientFactory.cs b/src/Pulse/Core/PulseHttpClientFactory.cs index a0ae578..ee84311 100644 --- a/src/Pulse/Core/PulseHttpClientFactory.cs +++ b/src/Pulse/Core/PulseHttpClientFactory.cs @@ -1,50 +1,56 @@ using System.Net; -using Sharpify; +using Pulse.Models; namespace Pulse.Core; /// /// Http client factory /// -public static class PulseHttpClientFactory { +internal 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) { +#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, true) { + 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 is null or { Length: 0 }) { + handler = new SocketsHttpHandler(); + } else { + var proxy = new WebProxy(proxyDetails.Host); + if (proxyDetails.Username.Length > 0 && proxyDetails.Password.Length > 0) { + 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 deleted file mode 100644 index f8a085d..0000000 --- a/src/Pulse/Core/PulseMonitor.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics; - -using static PrettyConsole.Console; -using PrettyConsole; -using Sharpify; - -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(); - } - - /// - 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); - } - } - - /// - public PulseResult Consolidate() => 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 deleted file mode 100644 index 7e56b08..0000000 --- a/src/Pulse/Core/PulseResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Concurrent; - -namespace Pulse.Core; - -/// -/// Result of pulse (complete test) -/// -public record PulseResult { - /// - /// Results of the individual requests - /// - public required ConcurrentStack Results { 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; } -} \ No newline at end of file diff --git a/src/Pulse/Core/PulseSummary.cs b/src/Pulse/Core/PulseSummary.cs index 120165b..2d541c5 100644 --- a/src/Pulse/Core/PulseSummary.cs +++ b/src/Pulse/Core/PulseSummary.cs @@ -1,302 +1,295 @@ 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.Models; + 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; +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, RequestDetails requestDetails, PulseResult pulseResult) { + var completed = pulseResult.Results.Count; + var requestSizeInBytes = requestDetails.Request.GetRequestLength(); + + if (completed is 1) { + await SummarizeSingleAsync(parameters, requestDetails, pulseResult).ConfigureAwait(false); + return; + } + + 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; + + if (!parameters.Quiet) { + ConsoleState.ReportLinesFromCurrent(1); + Console.Overwrite(() => { + Console.WriteInterpolated(OutputPipe.Error, $"Cross referencing results..."); + }); + } + + foreach (var result in pulseResult.Results) { + uniqueRequests.Add(result); + var statusCode = result.StatusCode; + CollectionsMarshal.GetValueRefOrAddDefault(statusCounter, 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); + double throughput = totalSize / pulseResult.TotalDuration.TotalSeconds; + + if (!parameters.Quiet) { + // Clear "cross-referencing results..." + Console.ClearNextLines(1); + } + + var output = new SummaryModel { + Target = new Target { + HttpMethod = requestDetails.Request.Method.Method, + Url = requestDetails.Request.Url + }, + 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, + ContentSizeInBytes = new MinMeanMax { + Min = sizeSummary.Min, + Mean = sizeSummary.Mean, + Max = sizeSummary.Max + }, + ThroughputBytesPerSecond = throughput, + StatusCodeCounts = statusCounter + }; + + output.Print(parameters.OutputFormat); + + 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) + 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; + var size = (double)result.ContentLength; + 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, + SuccessRate = pulseResult.SuccessRate, + LatencyInMilliseconds = new MinMeanMax { + Min = latency, + Mean = latency, + Max = latency, + }, + LatencyOutliersRemoved = 0, + ContentSizeInBytes = new MinMeanMax { + Min = size, + Mean = size, + Max = size + }, + ThroughputBytesPerSecond = throughput, + StatusCodeCounts = new Dictionary { + {statusCode, 1} + } + }; + + output.Print(parameters.OutputFormat); + + if (parameters.Export) { + var uniqueRequests = new HashSet(1) { result }; + + await ExportUniqueRequestsAsync(parameters, uniqueRequests).ConfigureAwait(false); + } + } + + + /// + /// Creates an IQR summary from + /// + /// + /// + /// + internal static Summary GetSummary(Span values, bool removeOutliers = true) { + switch (values.Length) { + case > 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); + } + 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(); + } + } + + 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 = CalculateMean(values), + Removed = removed + }; + } + + internal struct Summary { + public double Min; + public double Max; + public double Mean; + public int Removed; + } + + internal static double CalculateMean(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 + /// + /// + /// + /// + internal static async Task ExportUniqueRequestsAsync(Parameters parameters, HashSet uniqueRequests) { + var count = uniqueRequests.Count; + + if (count is 0) { + Console.WriteLineInterpolated($"{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, parameters.CancellationToken).ConfigureAwait(false); + Console.WriteLineInterpolated($"{Green}1{ConsoleColor.DefaultForeground} unique response exported to {Yellow}{directory}"); + return; + } + + var options = new ParallelOptions { + MaxDegreeOfParallelism = Environment.ProcessorCount, + CancellationToken = parameters.CancellationToken + }; + + await Parallel.ForEachAsync(uniqueRequests, options, async (request, tkn) => await Exporter.ExportResponseAsync(request, directory, parameters, tkn).ConfigureAwait(false)).ConfigureAwait(false); + + 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/Core/RawFailure.cs b/src/Pulse/Core/RawFailure.cs deleted file mode 100644 index b38db8a..0000000 --- a/src/Pulse/Core/RawFailure.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Pulse.Configuration; - -namespace Pulse.Core; - -/// -/// Represents a serializable way to display non successful response information when using -/// -public readonly struct RawFailure { - public RawFailure() { - Headers = []; - StatusCode = 0; - Content = string.Empty; - } - - /// - /// Response status code - /// - public int StatusCode { get; init; } - - /// - /// Response headers - /// - public Dictionary> Headers { get; init; } - - /// - /// 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 deleted file mode 100644 index 7bed6b0..0000000 --- a/src/Pulse/Core/ReleaseInfo.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Pulse.Core; - -/// -/// Release information -/// -public sealed class ReleaseInfo { - /// - /// 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 deleted file mode 100644 index 30c370a..0000000 --- a/src/Pulse/Core/RequestDetails.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; - -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 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; -} - -/// -/// 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); - } - } -} - -/// -/// 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; -} \ No newline at end of file diff --git a/src/Pulse/Core/Response.cs b/src/Pulse/Core/Response.cs deleted file mode 100644 index b8678e6..0000000 --- a/src/Pulse/Core/Response.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Net; - -using Pulse.Configuration; - -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; } -} - -/// -/// 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; - } -} \ 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 deleted file mode 100644 index 2485b49..0000000 --- a/src/Pulse/Core/VerbosePulseMonitor.cs +++ /dev/null @@ -1,100 +0,0 @@ -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; - -/// -/// 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; - - /// - /// 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 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(); - } - - /// - 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(); - } - } - - /// - public PulseResult Consolidate() => 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..b28e0cc --- /dev/null +++ b/src/Pulse/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.Runtime.CompilerServices; + +global using PrettyConsole; + +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 new file mode 100644 index 0000000..fd4cb59 --- /dev/null +++ b/src/Pulse/Models/CheckForUpdatesModel.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +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() { + 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."); + } + } +} \ No newline at end of file diff --git a/src/Pulse/Models/GetSampleModel.cs b/src/Pulse/Models/GetSampleModel.cs new file mode 100644 index 0000000..cb8dfb3 --- /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 new file mode 100644 index 0000000..9655cbf --- /dev/null +++ b/src/Pulse/Models/GlobalOptions.cs @@ -0,0 +1,8 @@ +namespace Pulse.Models; + +/// +/// Global options that can be used across commands. +/// +/// The output format to use +/// 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/IOutputFormatter.cs b/src/Pulse/Models/IOutputFormatter.cs new file mode 100644 index 0000000..6886a75 --- /dev/null +++ b/src/Pulse/Models/IOutputFormatter.cs @@ -0,0 +1,27 @@ +namespace Pulse.Models; + +internal interface IOutputFormatter { + void OutputAsPlainText(); + + void OutputAsJson(); +} + +internal enum OutputFormat { + PlainText, + JSON +} + +internal static class OutputFormatterExtensions { + internal static void Print(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/InfoModel.cs b/src/Pulse/Models/InfoModel.cs new file mode 100644 index 0000000..213e9ee --- /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/MinMeanMax.cs b/src/Pulse/Models/MinMeanMax.cs new file mode 100644 index 0000000..ffe0569 --- /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/ModelsJsonContext.cs b/src/Pulse/Models/ModelsJsonContext.cs new file mode 100644 index 0000000..360a9a5 --- /dev/null +++ b/src/Pulse/Models/ModelsJsonContext.cs @@ -0,0 +1,29 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Pulse.Models; + +[JsonSerializable(typeof(InfoModel))] +[JsonSerializable(typeof(GetSampleModel))] +[JsonSerializable(typeof(CheckForUpdatesModel))] +[JsonSerializable(typeof(RunConfiguration))] +[JsonSerializable(typeof(Parameters))] +[JsonSerializable(typeof(RequestDetails))] +[JsonSerializable(typeof(SummaryModel))] +[JsonSerializable(typeof(MinMeanMax))] +[JsonSerializable(typeof(Target))] +[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(); + Console.WriteLine(); + } + } +} \ No newline at end of file diff --git a/src/Pulse/Core/PaddedULong.cs b/src/Pulse/Models/PaddedULong.cs similarity index 71% rename from src/Pulse/Core/PaddedULong.cs rename to src/Pulse/Models/PaddedULong.cs index c89ed91..ac1fcdf 100644 --- a/src/Pulse/Core/PaddedULong.cs +++ b/src/Pulse/Models/PaddedULong.cs @@ -1,8 +1,8 @@ using System.Runtime.InteropServices; -namespace Pulse.Core; +namespace Pulse.Models; [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/Models/Parameters.cs b/src/Pulse/Models/Parameters.cs new file mode 100644 index 0000000..3e3eed8 --- /dev/null +++ b/src/Pulse/Models/Parameters.cs @@ -0,0 +1,80 @@ +namespace Pulse.Models; + +/// +/// Execution parameters +/// +internal record ParametersBase { + /// + /// 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; } + + /// + /// 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; } + + /// + /// The output format to use. + /// + public OutputFormat OutputFormat { get; init; } + + /// + /// Suppress progress output on stderr (only fatal errors will be shown) + /// + public bool Quiet { get; init; } + + /// + /// Output folder. + /// + public string OutputFolder { get; init; } = "results"; +} + +/// +/// Execution parameters +/// +internal sealed record Parameters : ParametersBase { + /// + /// 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/Models/PulseResult.cs b/src/Pulse/Models/PulseResult.cs new file mode 100644 index 0000000..95f2ca4 --- /dev/null +++ b/src/Pulse/Models/PulseResult.cs @@ -0,0 +1,23 @@ +using System.Collections.Concurrent; + +namespace Pulse.Models; + +/// +/// Result of pulse (complete test) +/// +internal readonly struct PulseResult { + /// + /// Results of the individual requests + /// + public required ConcurrentStack Results { 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; } +} \ No newline at end of file diff --git a/src/Pulse/Models/RawFailure.cs b/src/Pulse/Models/RawFailure.cs new file mode 100644 index 0000000..4261740 --- /dev/null +++ b/src/Pulse/Models/RawFailure.cs @@ -0,0 +1,29 @@ +using Pulse.Configuration; + +namespace Pulse.Models; + +/// +/// Represents a serializable way to display non successful response information when using +/// +internal readonly struct RawFailure { + public RawFailure() { + Headers = []; + StatusCode = 0; + Content = string.Empty; + } + + /// + /// Response status code + /// + public int StatusCode { get; init; } + + /// + /// Response headers + /// + public Dictionary> Headers { get; init; } + + /// + /// Response content (if any) + /// + public string Content { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/Pulse/Models/ReleaseInfo.cs b/src/Pulse/Models/ReleaseInfo.cs new file mode 100644 index 0000000..cd75895 --- /dev/null +++ b/src/Pulse/Models/ReleaseInfo.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Pulse.Models; + +/// +/// Release information +/// +internal sealed class ReleaseInfo { + /// + /// 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/Models/RequestDetails.cs b/src/Pulse/Models/RequestDetails.cs new file mode 100644 index 0000000..18efedd --- /dev/null +++ b/src/Pulse/Models/RequestDetails.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Pulse.Models; + +/// +/// Request details +/// +internal class RequestDetails { + /// + /// Proxy configuration + /// + public Proxy Proxy { get; set; } = new(); + + /// + /// Request configuration + /// + public Request Request { get; set; } = new(); +} + +/// +/// Proxy configuration +/// +internal 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; +} + +/// +/// Request configuration +/// +internal 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: " + 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 +/// +internal 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; +} \ No newline at end of file diff --git a/src/Pulse/Models/Response.cs b/src/Pulse/Models/Response.cs new file mode 100644 index 0000000..781f9ed --- /dev/null +++ b/src/Pulse/Models/Response.cs @@ -0,0 +1,119 @@ +using System.Net; + +using Pulse.Configuration; + +namespace Pulse.Models; + +/// +/// The model used for response +/// +internal 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; } +} + +/// +/// Request comparer to be used in HashSets +/// +internal 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 + 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/Models/RunConfiguration.cs b/src/Pulse/Models/RunConfiguration.cs new file mode 100644 index 0000000..e8a0729 --- /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 required Parameters Parameters { get; init; } + public required 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} OutputFormat: {value}{Parameters.OutputFormat}"); + Console.WriteLineInterpolated($"{property} Quiet: {value}{Parameters.Quiet}"); + 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 diff --git a/src/Pulse/Models/StrippedException.cs b/src/Pulse/Models/StrippedException.cs new file mode 100644 index 0000000..33dd351 --- /dev/null +++ b/src/Pulse/Models/StrippedException.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using Pulse.Configuration; +using Pulse.Core; + +namespace Pulse.Models; + +/// +/// An exception only containing the type, message and stack trace +/// +internal sealed record StrippedException : IOutputFormatter { + public static readonly StrippedException Default = new(); + + /// + /// Type of the exception + /// + public readonly string Type; + + /// + /// Message of the exception + /// + public readonly string Message; + + /// + /// Detail of the exception (if any) + /// + public readonly string? Detail; + + /// + /// 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; + + /// + /// 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); + } + + 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 + /// + /// + 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 and message + /// + /// + /// + public StrippedException(string type, string message) { + Type = type; + Message = message; + IsDefault = false; + } + + [JsonConstructor] + public StrippedException() { + Type = string.Empty; + Message = string.Empty; + IsDefault = true; + } +} \ 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..f84688a --- /dev/null +++ b/src/Pulse/Models/SummaryModel.cs @@ -0,0 +1,47 @@ +using System.Net; +using System.Text.Json; + +using Pulse.Core; + +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 void OutputAsJson() { + JsonSerializer.ToConsoleOut(in this, ModelsJsonContext.Default.SummaryModel); + } + + public void OutputAsPlainText() { + 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.DefaultForeground}, Mean: {Yellow}{LatencyInMilliseconds.Mean:0.##}ms{ConsoleColor.DefaultForeground}, Max: {Red}{LatencyInMilliseconds.Max:0.##}ms"); + if (LatencyOutliersRemoved != 0) { + Console.WriteLineInterpolated($" (Removed {DarkYellow}{LatencyOutliersRemoved}{ConsoleColor.DefaultForeground} {(LatencyOutliersRemoved == 1 ? "outlier" : "outliers")})"); + } + 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.DefaultForeground} --> {kvp.Value} [StatusCode 0 = Exception]"); + } else { + Console.WriteLineInterpolated($" {Helper.GetStatusCodeBasedColor(key)}{key}{ConsoleColor.DefaultForeground} --> {kvp.Value}"); + } + } + Console.NewLine(); + } +} \ No newline at end of file diff --git a/src/Pulse/Models/Target.cs b/src/Pulse/Models/Target.cs new file mode 100644 index 0000000..f883a08 --- /dev/null +++ b/src/Pulse/Models/Target.cs @@ -0,0 +1,6 @@ +namespace Pulse.Models; + +internal readonly struct Target { + 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 new file mode 100644 index 0000000..ceed37d --- /dev/null +++ b/src/Pulse/Models/TermsOfServiceModel.cs @@ -0,0 +1,22 @@ +using System.Collections.Immutable; +using System.Text.Json; + +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." + ]); + + public void OutputAsJson() { + JsonSerializer.ToConsoleOut(Lines, ModelsJsonContext.Default.ImmutableArrayString); + } + + 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 cc2e8fd..a2747c0 100644 --- a/src/Pulse/Program.cs +++ b/src/Pulse/Program.cs @@ -1,54 +1,29 @@ -using Pulse.Configuration; +using ConsoleAppFramework; + using Pulse.Core; +using Pulse.Models; + +ConsoleApp.Version = Commands.Version; + +var app = ConsoleApp.Create(); + +app.UseFilter(); + +app.ConfigureGlobalOptions((ref builder) => { + 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); +}); + +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("info", Commands.GetInfo); + +var schemaCommand = new CliSchemaCommand(app); + +app.Add("cli-schema", schemaCommand.Command); -using Sharpify.CommandLineInterface; - -using static PrettyConsole.Console; -using PrettyConsole; - -internal class Program { - internal const string VERSION = "1.2.0.0"; - - private static async Task Main(string[] args) { - using CancellationTokenSource globalCTS = new(); - - System.Console.CancelKeyPress += (_, e) => { - e.Cancel = true; - globalCTS.Cancel(); - }; - - var firstLine = GetCurrentLine(); - - 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 - - Repository: https://github.com/dusrdev/Pulse - """ - ) - .SetHelpTextSource(HelpTextSource.CustomHeader) - .Build(); - - 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).ConfigureAwait(false); \ No newline at end of file diff --git a/src/Pulse/Pulse.csproj b/src/Pulse/Pulse.csproj index da8435b..72b5a51 100644 --- a/src/Pulse/Pulse.csproj +++ b/src/Pulse/Pulse.csproj @@ -1,36 +1,46 @@  - - Exe - net9.0 - enable - enable - full - Speed - true - true - 1 - true - true - true - true - false - 1.2.0.0 - true - https://github.com/dusrdev/Pulse - git - + + Exe + net10.0 + enable + enable + full + preview + 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 + CA1515 + - - - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + - - - <_Parameter1>Pulse.Tests.Unit - - + + + <_Parameter1>Pulse.Tests + + - \ 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/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 f9ce90d..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.ConsoleColor); - } - - [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.ConsoleColor); - } -} \ 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 785ba1c..0000000 --- a/tests/Pulse.Tests.Unit/HttpClientFactoryTests.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Net; - -using Pulse.Configuration; - -using Pulse.Core; - -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, ParametersBase.DefaultTimeoutInMs); - - // 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, ParametersBase.DefaultTimeoutInMs); - - // 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 15bad10..0000000 --- a/tests/Pulse.Tests.Unit/ParametersTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Pulse.Configuration; - -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(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.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 ba2a042..0000000 --- a/tests/Pulse.Tests.Unit/Pulse.Tests.Unit.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - net9.0 - enable - enable - - false - true - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - 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/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/SummaryTests.cs b/tests/Pulse.Tests.Unit/SummaryTests.cs deleted file mode 100644 index f7baaeb..0000000 --- a/tests/Pulse.Tests.Unit/SummaryTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Pulse.Core; - -namespace Pulse.Tests.Unit; - -public class SummaryTests { - [Fact] - public void Summary_Mean_ReturnsCorrectValue() { - // Arrange - var arr = Enumerable.Range(0, 100).Select(_ => Random.Shared.NextDouble()).ToArray(); - var expected = arr.Average(); - - // Act - var actual = PulseSummary.Mean(arr); - - // Assert - Assert.Equal(expected, actual, 0.01); - } - - [Theory] - [ClassData(typeof(SummaryTestData))] - public void GetSummary_TheoryTests(double[] values, bool removeOutliers, double expectedMin, double expectedMax, double expectedAvg, int expectedRemoved) { - // Act - 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); - } - - 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); - } - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/VersionTests.cs b/tests/Pulse.Tests.Unit/VersionTests.cs deleted file mode 100644 index 80d2321..0000000 --- a/tests/Pulse.Tests.Unit/VersionTests.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Pulse.Tests.Unit; - -public class VersionTests { - [Fact] - public void Assembly_Version_Matching() { - // Arrange - var constantVersion = Version.Parse(Program.VERSION); - var assemblyVersion = typeof(Program).Assembly.GetName().Version!; - - // Assert - Assert.Equal(assemblyVersion, constantVersion); - } -} \ No newline at end of file diff --git a/tests/Pulse.Tests.Unit/ExporterTests.cs b/tests/Pulse.Tests/ExporterTests.cs similarity index 76% rename from tests/Pulse.Tests.Unit/ExporterTests.cs rename to tests/Pulse.Tests/ExporterTests.cs index 341d068..4bf7709 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); - 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); + 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); - 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); + 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); - 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); + 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); - 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); + 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); - 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,15 +376,13 @@ public async Task Exporter_ExportHtmlAsync_WithException_HasExceptionAndNoConten CurrentConcurrentConnections = 1 }; - // Act - await Exporter.ExportHtmlAsync(response, dirInfo.FullName); + 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); - 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); } diff --git a/tests/Pulse.Tests/HelperTests.cs b/tests/Pulse.Tests/HelperTests.cs new file mode 100644 index 0000000..30bcf45 --- /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); + } +} \ No newline at end of file diff --git a/tests/Pulse.Tests/HttpClientFactoryTests.cs b/tests/Pulse.Tests/HttpClientFactoryTests.cs new file mode 100644 index 0000000..dd89571 --- /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); + } +} \ No newline at end of file diff --git a/tests/Pulse.Tests/ParametersTests.cs b/tests/Pulse.Tests/ParametersTests.cs new file mode 100644 index 0000000..81201ae --- /dev/null +++ b/tests/Pulse.Tests/ParametersTests.cs @@ -0,0 +1,17 @@ +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(); + } +} \ No newline at end of file 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 51% rename from tests/Pulse.Tests.Unit/PulseMonitorTests.cs rename to tests/Pulse.Tests/PulseMonitorTests.cs index a26724d..c1bc13d 100644 --- a/tests/Pulse.Tests.Unit/PulseMonitorTests.cs +++ b/tests/Pulse.Tests/PulseMonitorTests.cs @@ -1,11 +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 { @@ -15,10 +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 Core.Pulse(httpClient, requestDetails.Request, parameters); - // 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); + 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 new file mode 100644 index 0000000..b66b45b --- /dev/null +++ b/tests/Pulse.Tests/ResponseComparerTests.cs @@ -0,0 +1,49 @@ +using System.Net; + +using Pulse.Models; + +namespace Pulse.Tests; + +public class ResponseComparerTests { + [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"); + var candidate = original with { + Id = 2, + Content = "bar", + ContentLength = 3 + }; + + await Assert.That(comparer.Equals(original, candidate)).IsTrue(); + await Assert.That(comparer.GetHashCode(original)).IsEqualTo(comparer.GetHashCode(candidate)); + } + + [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"); + var different = original with { + Id = 2, + Content = "foobar", + ContentLength = 6 + }; + + await Assert.That(comparer.Equals(original, different)).IsFalse(); + } + + 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/StrippedExceptionTests.cs b/tests/Pulse.Tests/StrippedExceptionTests.cs new file mode 100644 index 0000000..aae1115 --- /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); + } +} \ No newline at end of file diff --git a/tests/Pulse.Tests/SummaryTests.cs b/tests/Pulse.Tests/SummaryTests.cs new file mode 100644 index 0000000..8dab7cc --- /dev/null +++ b/tests/Pulse.Tests/SummaryTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using System.Net; + +using Pulse.Core; +using Pulse.Models; + +namespace Pulse.Tests; + +public class SummaryTests { + [Test] + public async Task Summary_Mean_ReturnsCorrectValue() { + var arr = Enumerable.Range(0, 100).Select(_ => Random.Shared.NextDouble()).ToArray(); + var expected = arr.Average(); + + var actual = PulseSummary.CalculateMean(arr); + + await Assert.That(Math.Abs(actual - expected)).IsLessThan(0.01); + } + + [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); + + 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); + } + + 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); + } + + [Test] + public async Task Summarize_DeduplicatesResponses_WhenExportEnabled() { + var outputFolderName = $"pulse-summary-tests-{Guid.NewGuid():N}"; + var parameters = new Parameters(new ParametersBase { + Export = true, + OutputFolder = outputFolderName, + Quiet = true + }, CancellationToken.None); + 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"), + CreateResponse(3, HttpStatusCode.OK, "beta") + }; + var stack = new ConcurrentStack(responses); + var pulseResult = new PulseResult { + Results = stack, + TotalDuration = TimeSpan.FromSeconds(1), + SuccessRate = 100 + }; + + try { + await PulseSummary.SummarizeAsync(parameters, requestDetails, pulseResult); + + var exportedFiles = Directory.Exists(exportDirectory) + ? Directory.GetFiles(exportDirectory) + : Array.Empty(); + + await Assert.That(exportedFiles.Length).IsEqualTo(2); + } finally { + if (Directory.Exists(exportDirectory)) { + Directory.Delete(exportDirectory, recursive: true); + } + } + } + + 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/VersionTests.cs b/tests/Pulse.Tests/VersionTests.cs new file mode 100644 index 0000000..72981b3 --- /dev/null +++ b/tests/Pulse.Tests/VersionTests.cs @@ -0,0 +1,13 @@ +using Pulse.Core; + +namespace Pulse.Tests; + +public class VersionTests { + [Test] + public async Task Assembly_Version_Matching() { + var constantVersion = Version.Parse(Commands.Version); + var assemblyVersion = typeof(Program).Assembly.GetName().Version!; + + await Assert.That(constantVersion).IsEqualTo(assemblyVersion); + } +} \ No newline at end of file