diff --git a/.docfx/.dockerignore b/.docfx/.dockerignore new file mode 100644 index 000000000..c063843c1 --- /dev/null +++ b/.docfx/.dockerignore @@ -0,0 +1,5 @@ +# Local outputs and bookkeeping are not inputs to the container's DocFX build. +wwwroot/ +obj/ +bin/ +*.ps1 diff --git a/.docfx/BuildDocfxImage.ps1 b/.docfx/BuildDocfxImage.ps1 index 65016d084..3d88c39da 100644 --- a/.docfx/BuildDocfxImage.ps1 +++ b/.docfx/BuildDocfxImage.ps1 @@ -1,4 +1,171 @@ -$version = minver -i -t v -v w -docfx metadata docfx.json -docker buildx build -t cuemon-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . -get-childItem -recurse -path api -include *.yml, .manifest | remove-item +[CmdletBinding()] +param( + # Regenerate metadata even when the inputs and generated files are unchanged. + [switch] $ForceMetadata +) + +$ErrorActionPreference = 'Stop' + +function Assert-CommandSucceeded([string] $Command) { + if ($LASTEXITCODE -ne 0) { throw "$Command failed with exit code $LASTEXITCODE." } +} + +function Get-SourceFiles([string] $Directory) { + foreach ($item in Get-ChildItem -LiteralPath $Directory -Force) { + if ($item.PSIsContainer) { + if ($item.Name -notin @('bin', 'obj', '.git')) { Get-SourceFiles $item.FullName } + } else { + $item.FullName + } + } +} + +function Get-Fingerprint([string[]] $Paths, [string[]] $Values = @()) { + $entries = @( + $Values + foreach ($path in $Paths | Sort-Object -Unique) { + if (Test-Path -LiteralPath $path -PathType Leaf) { + "$path=$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash)" + } else { + "$path=" + } + } + ) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($entries -join [Environment]::NewLine)))) + } finally { + $sha.Dispose() + } +} + +function Get-MetadataFiles { + foreach ($directory in $metadataDirectories) { + if (Test-Path -LiteralPath $directory) { + Get-ChildItem -LiteralPath $directory -Recurse -Force -File | + Where-Object { $_.Extension -eq '.yml' -or $_.Name -eq '.manifest' } | + Select-Object -ExpandProperty FullName + } + } +} + +$totalTimer = [Diagnostics.Stopwatch]::StartNew() +Push-Location $PSScriptRoot +try { + $docfxRoot = $PSScriptRoot + $repoRoot = Split-Path -Parent $docfxRoot + $sourceRoot = Join-Path $repoRoot 'src' + $docfxConfig = Get-Content -LiteralPath (Join-Path $docfxRoot 'docfx.json') -Raw | ConvertFrom-Json + $version = minver -i -t v -v w + Assert-CommandSucceeded 'minver' + $docfxVersion = docfx --version + Assert-CommandSucceeded 'docfx --version' + $sdkVersion = dotnet --version + Assert-CommandSucceeded 'dotnet --version' + $revision = git rev-parse HEAD + Assert-CommandSucceeded 'git rev-parse HEAD' + $branch = git rev-parse --abbrev-ref HEAD + Assert-CommandSucceeded 'git rev-parse --abbrev-ref HEAD' + + # Only these generated destinations may be cleaned; preserve authored Markdown. + $apiRoot = [IO.Path]::GetFullPath((Join-Path $docfxRoot 'api')) + [IO.Path]::DirectorySeparatorChar + $metadataDirectories = @( + foreach ($metadata in $docfxConfig.metadata) { + $destination = [IO.Path]::GetFullPath((Join-Path $docfxRoot $metadata.dest)) + if (-not $destination.StartsWith($apiRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw "Metadata destination must be beneath $apiRoot : $destination" + } + $destination + } + ) + $sourceFiles = @(Get-SourceFiles $sourceRoot) + $sourceProjects = @($sourceFiles | Where-Object { [IO.Path]::GetExtension($_) -eq '.csproj' }) + $inputPaths = @( + $PSCommandPath + $sourceFiles + Get-ChildItem -LiteralPath $repoRoot -Force -File | Select-Object -ExpandProperty FullName + foreach ($metadata in $docfxConfig.metadata) { + if ($metadata.filter) { Join-Path $docfxRoot $metadata.filter } + } + $inputDirectory = $repoRoot + while ($inputDirectory) { + foreach ($name in @('Directory.Build.props', 'Directory.Build.targets', 'Directory.Packages.props', 'NuGet.Config', 'global.json')) { + Join-Path $inputDirectory $name + } + $inputDirectory = Split-Path -Parent $inputDirectory + } + if ($env:APPDATA) { Join-Path $env:APPDATA 'NuGet/NuGet.Config' } + foreach ($project in $sourceProjects) { + $objDirectory = Join-Path (Split-Path -Parent $project) 'obj' + Join-Path $objDirectory 'project.assets.json' + Join-Path $objDirectory ((Split-Path -Leaf $project) + '.nuget.g.props') + Join-Path $objDirectory ((Split-Path -Leaf $project) + '.nuget.g.targets') + } + ) + $inputValues = @( + $version; $docfxVersion; $sdkVersion; $revision; $branch + $docfxConfig.metadata | ConvertTo-Json -Depth 100 -Compress + foreach ($name in @('CI', 'EMAIL', 'Configuration', 'MSBuildSDKsPath', 'DOTNET_ROOT', 'NUGET_PACKAGES', 'GITHUB_RUN_NUMBER')) { + "$name=$([Environment]::GetEnvironmentVariable($name))" + } + ) + $cachePath = Join-Path $docfxRoot 'obj/metadata-cache.json' + $inputFingerprint = Get-Fingerprint $inputPaths $inputValues + $metadataFiles = @(Get-MetadataFiles) + $cache = $null + if (Test-Path -LiteralPath $cachePath) { + try { + $cache = Get-Content -LiteralPath $cachePath -Raw | ConvertFrom-Json + } catch { + Write-Warning 'Metadata cache could not be read; regenerating it.' + } + } + $reuseMetadata = -not $ForceMetadata -and $cache -and + $cache.inputs -eq $inputFingerprint -and $metadataFiles.Count -gt 0 -and + $cache.outputs -eq (Get-Fingerprint $metadataFiles) + + if ($reuseMetadata) { + Write-Host 'Metadata unchanged; reusing verified generated files.' + } else { + $metadataTimer = [Diagnostics.Stopwatch]::StartNew() + # One restore graph replaces DocFX's separate restore for each project. + $restoreSolution = Join-Path ([IO.Path]::GetTempPath()) ("cuemon-docfx-{0}.slnx" -f [Guid]::NewGuid()) + try { + $projectsXml = foreach ($project in $sourceProjects) { + ' ' -f [Security.SecurityElement]::Escape($project) + } + @('') + $projectsXml + @('') | Set-Content -LiteralPath $restoreSolution -Encoding utf8 + dotnet restore $restoreSolution --verbosity quiet + Assert-CommandSucceeded 'dotnet restore' + } finally { + if (Test-Path -LiteralPath $restoreSolution) { Remove-Item -LiteralPath $restoreSolution } + } + + # Invalidate before generation so an interrupted or failed run cannot be reused. + if (Test-Path -LiteralPath $cachePath) { Remove-Item -LiteralPath $cachePath } + foreach ($file in $metadataFiles) { Remove-Item -LiteralPath $file } + $generationFingerprint = Get-Fingerprint $inputPaths $inputValues + # Keep all groups in one process; DocFX carries resolver state across groups. + docfx metadata docfx.json --noRestore + Assert-CommandSucceeded 'docfx metadata' + $metadataFiles = @(Get-MetadataFiles) + if ($metadataFiles.Count -eq 0) { throw 'DocFX did not generate metadata.' } + if ((Get-Fingerprint $inputPaths $inputValues) -ne $generationFingerprint) { + throw 'Metadata inputs changed during generation; rerun the build.' + } + $cache = @{ + inputs = $generationFingerprint + outputs = Get-Fingerprint $metadataFiles + } + New-Item -ItemType Directory -Path (Split-Path -Parent $cachePath) -Force | Out-Null + $cache | ConvertTo-Json | Set-Content -LiteralPath $cachePath -Encoding utf8 + Write-Host ('Metadata and restore: {0:N2}s' -f $metadataTimer.Elapsed.TotalSeconds) + } + + $imageTimer = [Diagnostics.Stopwatch]::StartNew() + docker buildx build -t cuemon-docfx:$version --platform linux/arm64,linux/amd64 --load -f Dockerfile.docfx . + Assert-CommandSucceeded 'docker buildx build' + Write-Host ('Docker image: {0:N2}s; total: {1:N2}s' -f $imageTimer.Elapsed.TotalSeconds, $totalTimer.Elapsed.TotalSeconds) +} finally { + Pop-Location +} diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index 7d97dde52..c6698efce 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -3,15 +3,14 @@ FROM --platform=$BUILDPLATFORM nginx:${NGINX_VERSION} AS base RUN rm -rf /usr/share/nginx/html/* -FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2.78.5 AS build +FROM --platform=$BUILDPLATFORM codebeltnet/docfx:2 AS build ADD [".", "docfx"] RUN cd docfx; \ docfx build -FROM nginx:${NGINX_VERSION} AS final +FROM dhi.io/nginx:${NGINX_VERSION} AS final WORKDIR /usr/share/nginx/html COPY --from=build /build/docfx/wwwroot /usr/share/nginx/html - -ENTRYPOINT ["nginx", "-g", "daemon off;"] +COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/.docfx/api/extensions/index.md b/.docfx/api/extensions/index.md deleted file mode 100644 index 3f2a5e14b..000000000 --- a/.docfx/api/extensions/index.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -uid: other-projects-md -title: Other Projects ---- - -# Other Projects - -Here is a collection of various non-Microsoft APIs that was adapted by Cuemon for .NET to provide an abundance of enhancements, new features and extension methods. - -## xUnit API - -[xUnit.net](https://xunit.net/) has from day one been the preferred unit test platform for Cuemon for .NET and it was only natural to extend upon xUnit for even more advanced unit test scenarios. - -[!INCLUDE [availability-hybrid](../../includes/availability-hybrid.md)] - -Complements: [xUnit.net](https://github.com/xunit/xunit) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for xUnit API by Codebelt](https://github.com/codebeltnet/xunit). - -## Json.NET API - -I am a huge fan of [Json.NET](https://www.newtonsoft.com/json) written by [James Newton-King](https://github.com/JamesNK) and the flexible architecture this JSON framework adds to the toolbelt. - -So even though Microsoft decided to write their own [JSON framework](https://docs.microsoft.com/en-us/dotnet/api/system.text.json) (first seen with the release of ASP.NET Core 3), Cuemon for .NET will continue to support and extend Json.NET as the natural companion of our framework. - -[!INCLUDE [availability-hybrid](../../includes/availability-hybrid.md)] - -Complements: [Json.NET](https://github.com/JamesNK/Newtonsoft.Json) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for Newtonsoft.Json API by Codebelt](https://github.com/codebeltnet/newtonsoft-json). - -## Asp.Versioning API - -Our preferred way of versioning Open API/Swagger for RESTful APIs is done through [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning). - -[!INCLUDE [availability-modern](../../includes/availability-modern.md)] - -Complements: [Asp.Versioning](https://github.com/dotnet/aspnet-api-versioning) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for Asp.Versioning API by Codebelt](https://github.com/codebeltnet/asp-versioning). - -## Swashbuckle.AspNetCore API - -[Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) defined the standard for Open API/Swagger which we built upon to provide an even more powerful and efficient way of documenting your RESTful APIs. - -[!INCLUDE [availability-modern](../../includes/availability-modern.md)] - -Complements: [Swashbuckle.AspNetCore](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for Swashbuckle.AspNetCore API by Codebelt](https://github.com/codebeltnet/swashbuckle-aspnetcore). - -## YamlDotNet API - -[YamlDotNet](https://github.com/aaubry/YamlDotNet) is the most matured YAML library for .NET, why we decided to abandon own efforts to write a YAML library from scratch. That written, we happily built upon `YamlDotNet` to provide an even better developer experience when working with YAML. - -[!INCLUDE [availability-modern](../../includes/availability-modern.md)] - -Complements: [YamlDotNet](https://github.com/aaubry/YamlDotNet/wiki) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for YamlDotNet API by Codebelt](https://github.com/codebeltnet/yamldotnet). - -## AWS Signature API - -Providing an additional HTTP HMAC Authentication header that provides a fluent way to use [AWS Signature Version 4](https://docs.aws.amazon.com/general/latest/gr/reference-for-signature-version-4.html) was a fun challenge to write and add to Cuemon for .NET . - -[!INCLUDE [availability-modern](../../includes/availability-modern.md)] - -Complements: [Authenticating Requests (AWS Signature Version 4)](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) 🔗 - -> **Note** -> Since `Cuemon for .NET` has always been about extending official .NET APIs, this project is no longer maintained as part of the Cuemon assembly family. It has been moved to its own repository and is now called [Extensions for AWS Signature Version 4 API by Codebelt](https://github.com/codebeltnet/aws-signature-v4). diff --git a/.docfx/docfx.json b/.docfx/docfx.json index f3ca71ffe..2ffac3331 100644 --- a/.docfx/docfx.json +++ b/.docfx/docfx.json @@ -22,6 +22,7 @@ } ], "dest": "api/dotnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -53,6 +54,7 @@ } ], "dest": "api/extensions/dotnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -71,6 +73,7 @@ } ], "dest": "api/aspnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -93,6 +96,7 @@ } ], "dest": "api/extensions/aspnet", + "memberLayout": "separatePages", "filter": "filterConfig.yml", "properties": { "TargetFramework": "net10.0" @@ -135,12 +139,11 @@ "_appLogoPath": "images/50x50.png", "_appFaviconPath": "images/favicon.ico", "_googleAnalyticsTagId": "UA-126254455-1", - "_disableBreadcrumb": true, "_enableSearch": true, "_disableContribution": false, "_gitContribute": { "repo": "https://github.com/codebeltnet/cuemon", - "branch": "development" + "branch": "main" }, "_gitUrlPattern": "github", "_lang": "en" @@ -151,7 +154,8 @@ "template": [ "default", "modern", - "templates/cuemon" + "templates/cuemon", + "templates/ms-style" ], "overwrite": [ { @@ -170,6 +174,10 @@ "noLangKeyword": false, "keepFileLink": false, "cleanupCacheHistory": false, - "disableGitFeatures": false + "disableGitFeatures": false, + "sitemap": { + "baseUrl": "https://docs.cuemon.net/", + "changefreq": "monthly" + } } } diff --git a/.docfx/nginx.conf b/.docfx/nginx.conf new file mode 100644 index 000000000..c95d1b1b7 --- /dev/null +++ b/.docfx/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 8080; + server_name _; + # The ingress terminates TLS; keep redirects from exposing this container port. + absolute_redirect off; + + root /usr/share/nginx/html; + index index.html; + + # DocFX fingerprints these generated dependencies in their filenames. They can be + # cached permanently because changed content is published under a different URL. + location ~ "^/public/.+-[A-Z0-9]{8}(?:-[A-Z0-9]{8})?(?:\.min)?\.(?:(?:css|js)(?:\.map)?|woff2?)$" { + try_files $uri =404; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header Vary "Accept-Encoding" always; + } + + # Pages, navigation data, images, and DocFX entry assets keep stable URLs. Do not + # retain them across deployments, and ignore validators cached under an old policy. + location / { + # Nginx derives ETags from modification time and file size. Those values can be + # reused by files copied from different image layers, producing a false 304. + etag off; + if_modified_since off; + try_files $uri $uri/ =404; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; + add_header Expires "0" always; + add_header Pragma "no-cache" always; + add_header Vary "Accept-Encoding" always; + } +} diff --git a/.docfx/templates/cuemon/layout/_master.tmpl b/.docfx/templates/cuemon/layout/_master.tmpl index 336a8d73d..91f3db938 100644 --- a/.docfx/templates/cuemon/layout/_master.tmpl +++ b/.docfx/templates/cuemon/layout/_master.tmpl @@ -97,6 +97,20 @@
+
+ {{^_disableToc}} + + {{/_disableToc}} + + {{^_disableBreadcrumb}} + + {{/_disableBreadcrumb}} +
+
{{!body}}
diff --git a/.docfx/toc.yml b/.docfx/toc.yml index 08bf7441a..5b8fbcdac 100644 --- a/.docfx/toc.yml +++ b/.docfx/toc.yml @@ -16,7 +16,5 @@ href: api/aspnet/Cuemon.AspNetCore.html - name: ASP.NET Core API Extensions href: api/extensions/aspnet/Cuemon.Extensions.AspNetCore.Configuration.html -- name: Other Projects - href: api/extensions - name: NuGet href: packages diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index aabea97e4..7ef98cad9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -1,10 +1,12 @@ -name: Scorecard supply-chain security +name: Scorecard analysis workflow on: - branch_protection_rule: - schedule: - - cron: '45 17 * * 2' push: - branches: [ "main" ] + # Only the default branch is supported. + branches: + - main + schedule: + # Weekly on Saturdays. + - cron: '30 1 * * 6' permissions: read-all @@ -13,30 +15,42 @@ jobs: name: Scorecard analysis runs-on: ubuntu-latest permissions: + # Needed for Code scanning upload security-events: write + # Needed for GitHub OIDC token if publish_results is true id-token: write steps: - name: "Checkout code" - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@v2.4.0 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif + # Scorecard team runs a weekly scan of public GitHub repos, + # see https://github.com/ossf/scorecard#public-data. + # Setting `publish_results: true` helps us scale by leveraging your workflow to + # extract the results instead of relying on our own infrastructure to run scans. + # And it's free for you! publish_results: true + # Upload the results as artifacts (optional). Commenting out will disable + # uploads of run results in SARIF format to the repository Actions tab. + # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts - name: "Upload artifact" - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif retention-days: 5 + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/Directory.Build.props b/Directory.Build.props index 7604c1d9a..14fb0414f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -91,13 +91,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Directory.Packages.props b/Directory.Packages.props index df200295a..dc165c904 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,18 +7,20 @@ - - - - - - + + + + + + + - - + + + @@ -35,12 +37,12 @@ - - - - - - + + + + + + @@ -51,6 +53,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index 1f9d9cb37..fa415616a 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Provides a focused API for building various types of .NET projects. | [Cuemon.Extensions.Data.Integrity](https://www.nuget.org/packages/Cuemon.Extensions.Data.Integrity/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.Data.Integrity?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.Data.Integrity?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.Data.Integrity?color=blueviolet&logo=nuget) | | [Cuemon.Extensions.DependencyInjection](https://www.nuget.org/packages/Cuemon.Extensions.DependencyInjection/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.DependencyInjection?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.DependencyInjection?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.DependencyInjection?color=blueviolet&logo=nuget) | | [Cuemon.Extensions.Diagnostics](https://www.nuget.org/packages/Cuemon.Extensions.Diagnostics/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.Diagnostics?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.Diagnostics?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.Diagnostics?color=blueviolet&logo=nuget) | +| [Cuemon.Extensions.FileProviders.Physical](https://www.nuget.org/packages/Cuemon.Extensions.FileProviders.Physical/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.FileProviders.Physical?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.FileProviders.Physical?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.FileProviders.Physical?color=blueviolet&logo=nuget) | | [Cuemon.Extensions.Hosting](https://www.nuget.org/packages/Cuemon.Extensions.Hosting/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.Hosting?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.Hosting?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.Hosting?color=blueviolet&logo=nuget) | | [Cuemon.Extensions.IO](https://www.nuget.org/packages/Cuemon.Extensions.IO/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.IO?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.IO?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.IO?color=blueviolet&logo=nuget) | | [Cuemon.Extensions.Net](https://www.nuget.org/packages/Cuemon.Extensions.Net/) | ![vNext](https://img.shields.io/nuget/vpre/Cuemon.Extensions.Net?logo=nuget) | ![Stable](https://img.shields.io/nuget/v/Cuemon.Extensions.Net?logo=nuget) | ![Downloads](https://img.shields.io/nuget/dt/Cuemon.Extensions.Net?color=blueviolet&logo=nuget) | diff --git a/test/Cuemon.Extensions.FileProviders.Physical.Tests/PortablePhysicalFileProviderTest.cs b/test/Cuemon.Extensions.FileProviders.Physical.Tests/PortablePhysicalFileProviderTest.cs index 22a82ca92..244abe21c 100644 --- a/test/Cuemon.Extensions.FileProviders.Physical.Tests/PortablePhysicalFileProviderTest.cs +++ b/test/Cuemon.Extensions.FileProviders.Physical.Tests/PortablePhysicalFileProviderTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -21,6 +22,7 @@ namespace Cuemon.Extensions.FileProviders; public class PortablePhysicalFileProviderTest : Test { private static readonly TimeSpan ChangeNotificationTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ChangeNotificationRetryInterval = TimeSpan.FromMilliseconds(250); private static readonly FindMatchingEntryDelegate FindMatchingEntry = CreateFindMatchingEntryDelegate(); private static readonly ResolvePathDelegate ResolvePathWithEntries = CreateResolvePathDelegate(); private static readonly ResolveFileInfoDelegate ResolveFileInfoSelection = CreateResolveFileInfoDelegate(); @@ -853,7 +855,7 @@ public async Task Watch_ShouldResolveLiteralDirectoryFilterWithTrailingSeparator AssertEquivalentChangeToken(baseline, token); Assert.False(ReferenceEquals(NullChangeToken.Singleton, token)); - await AssertEquivalentChangeNotificationAsync(baseline, token, () => File.WriteAllText(Path.Combine(directoryPath, "new.txt"), Guid.NewGuid().ToString("N"))); + await AssertEquivalentChangeNotificationAsync(baseline, token, () => File.WriteAllText(Path.Combine(directoryPath, $"{Guid.NewGuid():N}.txt"), Guid.NewGuid().ToString("N"))); } [Fact] @@ -1219,7 +1221,7 @@ private static async Task AssertEquivalentChangeNotificationAsync(IChangeToken e var expectedChanged = WaitForChangeAsync(expected); var actualChanged = WaitForChangeAsync(actual); - changeAction(); + await SignalChangesUntilCompletedAsync(changeAction, expectedChanged, actualChanged).ConfigureAwait(false); var notifications = await Task.WhenAll(expectedChanged, actualChanged).ConfigureAwait(false); @@ -1229,6 +1231,32 @@ private static async Task AssertEquivalentChangeNotificationAsync(IChangeToken e Assert.True(actual.HasChanged); } + private static async Task SignalChangesUntilCompletedAsync(Action changeAction, params Task[] changeTasks) + { + var allChanges = Task.WhenAll(changeTasks); + var timer = Stopwatch.StartNew(); + + // Polling-based watchers can miss the first mutation while the subscription is still priming. + while (timer.Elapsed < ChangeNotificationTimeout && !allChanges.IsCompleted) + { + changeAction(); + + if (allChanges.IsCompleted) + { + return; + } + + var remaining = ChangeNotificationTimeout - timer.Elapsed; + if (remaining <= TimeSpan.Zero) + { + return; + } + + var delay = remaining < ChangeNotificationRetryInterval ? remaining : ChangeNotificationRetryInterval; + await Task.WhenAny(allChanges, Task.Delay(delay)).ConfigureAwait(false); + } + } + private static async Task WaitForChangeAsync(IChangeToken token) { var changed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);