From 24d5d13fc48b3a0c861f62dd646ae894f42d0076 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 18:15:39 -0600 Subject: [PATCH 1/2] ci: run the adapter tests and fuzz campaigns on every push The Gitea build now runs the Windows adapter tests (everything in the UiTests assembly outside the new Desktop category) and the seeded parser campaigns: 500 iterations on topic branches, 10000 on main and tags. It clears the results directory first, because the host runner keeps its workspace, and no longer passes secrets it does not use. The coverage gate rejects a package with no measured lines, names the report it used, and lists only that report's uncovered lines. The core project no longer locks the SDK linker package: no project publishes as a single file, and the entry broke locked restore whenever the SDK patch level moved. Locked self-contained publishing of the app and the update probe still restores. Developer docs give the commands CI runs, say which checks have no automated run while the GitHub mirror has Actions switched off, and state that a tag currently publishes nothing. Test temp folders and the test registry parent key are removed when the last test is done. Closes #54 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- .gitea/workflows/build.yaml | 11 +++- CLAUDE.md | 10 +++- CONTRIBUTING.md | 2 +- DEVELOPERS.md | 28 ++++++--- build/Assert-Coverage.Tests.ps1 | 50 +++++++++++++++- build/Assert-Coverage.ps1 | 59 +++++++++++++------ coverage.runsettings | 7 +-- docs/RELEASING.md | 7 +++ docs/installed-updates.md | 6 +- .../MarkdownViewr.Windows.Core.csproj | 2 - .../packages.lock.json | 6 -- .../MarkdownViewr.Windows.Core.Tests/Fakes.cs | 8 ++- .../MarkdownViewr.Windows.UiTests/AppTests.cs | 1 + .../RegistryFixture.cs | 13 +++- 14 files changed, 157 insertions(+), 53 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index be341ea..89a7172 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -6,7 +6,6 @@ on: jobs: windows: uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/windows.yaml@v1 - secrets: inherit with: commands: | dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode @@ -16,8 +15,18 @@ jobs: $env:Platform = 'x64' dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore Remove-Item Env:Platform + # The host runner keeps its workspace between runs; the coverage gate must see this run's report only. + if (Test-Path TestResults) { Remove-Item TestResults -Recurse -Force } dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --logger trx --results-directory TestResults pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core + # Windows adapters (files, watcher, registry) against the real operating system. The Desktop + # category drives the app with mouse and keyboard and needs an interactive session. + dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category!=Desktop" --logger trx --results-directory TestResults + # Seeded parser campaigns: a longer run on main and on tags than on topic branches. + $env:K22_FUZZ_ITERATIONS = ($env:GITHUB_REF_NAME -eq 'main' -or $env:GITHUB_REF_TYPE -eq 'tag') ? '10000' : '500' + dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --filter "Category=Fuzz" + dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Fuzz" + Remove-Item Env:K22_FUZZ_ITERATIONS # PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory. $modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID" New-Item -ItemType Directory -Force -Path $modules | Out-Null diff --git a/CLAUDE.md b/CLAUDE.md index bc7fbba..afaf43f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,12 +9,16 @@ its Windows desktop reference architecture. ```powershell dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror -dotnet test MarkdownViewr.Windows.slnx -p:Platform=x64 --no-build --filter "FullyQualifiedName!~UiTests" --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults -./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -dotnet test tests/MarkdownViewr.Windows.UiTests --no-build # launches the app on the desktop +dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults # TestResults must start empty +pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core +dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category!=Desktop" # Windows adapters; no window, no input +dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Desktop" # drives the app with mouse and keyboard dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore ``` +Build through the solution. Passing `-p:Platform=x64` to one test project selects a different +output folder, so a following `--no-build` test run would use stale binaries. + ## Rules - GitHub Flow: topic branch `/` → PR to `main` → squash merge. Conventional diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 420b857..8125346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ This Product follows the [KofTwentyTwo standards](https://github.com/KofTwentyTw - Preserve original MIT notices and dependency credits. - Disclose substantial AI assistance and include a `Co-Authored-By:` trailer naming the model. -Required CI covers build/coverage/style, reader and installed-update acceptance, parser fuzzing, tooling tests, dependency/secret/workflow scans, CodeQL, PR title, and DCO. +CI runs on every push and covers the build, formatting, core tests with the coverage gate, Windows adapter tests, parser fuzzing, and tooling tests. Desktop and installed-update acceptance, the dependency, secret, and workflow scans, and CodeQL are not automated at present; [DEVELOPERS.md](DEVELOPERS.md#what-runs-where) lists what to run by hand. Local hooks check the commit title, the sign-off, and secrets. Under the single-maintainer policy, `review / automated` also requires a genuine current-commit acknowledgement from the configured human maintainer. An agent cannot provide that acknowledgement. See [repository setup](docs/repository-setup.md). diff --git a/DEVELOPERS.md b/DEVELOPERS.md index c22b7ce..c2ebc5b 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -28,26 +28,40 @@ To isolate preview data, set `MARKDOWNVIEWR_WINDOWS_DATA_DIR` for that process t ## Tests and quality gates +After the solution build above: + ```powershell -dotnet test tests/MarkdownViewr.Windows.Core.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults -./build/Assert-Coverage.ps1 -ResultsDirectory TestResults +if (Test-Path TestResults) { Remove-Item TestResults -Recurse -Force } +dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults +pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core +dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category!=Desktop" dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore npm ci --ignore-scripts npm run lint npm test ``` -Core tests use xUnit and Coverlet; retain 100% measured core line coverage. Name tests `Method_Scenario_Expectation` and add meaningful regression coverage for fixes. Deterministic tests inject time and filesystem boundaries. +Core tests use xUnit and Coverlet; retain 100% measured core line coverage. The coverage gate reads every report under the results directory, so start from an empty one, and run the gate as its own process: called in-process, a failure does not stop the calling script. Name tests `Method_Scenario_Expectation` and add meaningful regression coverage for fixes. Deterministic tests inject time and filesystem boundaries. -Native desktop tests use FlaUI/UIA3 and require an available interactive desktop: +The `UiTests` assembly holds two kinds of test. Adapter tests exercise the Windows file, watcher, and registry code against the real operating system without opening a window. Tests in the `Desktop` category use FlaUI/UIA3 to drive the app with the mouse and keyboard and need an interactive desktop: ```powershell -dotnet test tests/MarkdownViewr.Windows.UiTests +dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Desktop" ``` -Reserve the desktop before running physical-input tests. Hosted CI also exercises published output and actual stable/dev installation, update/restart, registration, and coexistence. See [installed-update acceptance](docs/installed-updates.md). Synthetic unsigned fixtures do not establish production signing. +Reserve the desktop before running them. Build through the solution: passing `-p:Platform=x64` to one test project selects a different output folder, and a following `--no-build` run would test stale binaries. -For PowerShell, use PSScriptAnalyzer 1.25.0 with `PSScriptAnalyzerSettings.psd1`, then Pester 5.7.1 against `build/*.Tests.ps1`. Reproduce the exact sequential analyzer partitions and negative fixtures in [scripts.yml](.github/workflows/scripts.yml); engine exceptions and rule findings are failures. +For PowerShell, use PSScriptAnalyzer 1.25.0 with `PSScriptAnalyzerSettings.psd1`, then Pester 5.7.1 against `build/*.Tests.ps1`. Reproduce the exact sequential analyzer partitions in [build.yaml](.gitea/workflows/build.yaml); engine exceptions and rule findings are failures. + +### What runs where + +The repository is hosted on Gitea, with GitHub as a mirror. [build.yaml](.gitea/workflows/build.yaml) runs on every push to any branch: locked restore, zero-warning build, formatting, core tests with the coverage gate, the adapter tests, the seeded parser campaigns, script analysis with Pester, and the JavaScript lint and tests. A pull request shows the result for its head commit. + +The workflows under `.github/` belong to the mirror, where GitHub Actions is currently switched off. Until it is switched on again, these checks have no automated run and are run by hand before a release: + +- the `Desktop` tests, against a local build and against published output; +- [installed-update acceptance](docs/installed-updates.md): actual stable and development installation, update and restart, registration, and coexistence. Its synthetic unsigned fixtures do not establish production signing; +- secret, dependency, and workflow scans, and CodeQL. Run the [seeded parser campaigns](docs/security/fuzzing.md) when changing parser or activation boundaries. Never use private documents as a corpus. diff --git a/build/Assert-Coverage.Tests.ps1 b/build/Assert-Coverage.Tests.ps1 index 8b4bf68..e296e6e 100644 --- a/build/Assert-Coverage.Tests.ps1 +++ b/build/Assert-Coverage.Tests.ps1 @@ -19,16 +19,19 @@ BeforeAll ` [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Directory, - [Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate + [Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate, + [switch] $NoLines ) $rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture) $secondLineHits = if ($Rate -eq 1) { 1 } else { 0 } + $lines = if ($NoLines) { '' } else { "" } $report = @" - +$lines "@ + New-Item -ItemType Directory -Force -Path $Directory | Out-Null Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report } } @@ -71,4 +74,47 @@ Describe 'Required coverage gate' ` $LASTEXITCODE | Should -Be 0 $output | Should -Match 'Coverage gate passed' } + + It 'names the uncovered lines of a package below the bar' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'uncovered: Probe\.cs lines 2(\s|$)' + } + + It 'rejects a package no report measured a line of' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 -NoLines + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'no measured lines' + } + + It 'ignores an empty report when another test project measured the package' ` + { + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Rate 1 -NoLines + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Rate 0.5 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 1 + $output | Should -Match 'Probe line coverage: 50% of 2 lines' + } + + It 'takes the test project that covers the package best' ` + { + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Rate 0.5 + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Rate 1 + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory 2>&1) -join "`n" + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'Probe line coverage: 100% of 2 lines .*second' + } + + It 'holds the package to a lower bar when one is given' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 + & pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -MinimumPercent 50 | Out-Null + $LASTEXITCODE | Should -Be 0 + & pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -MinimumPercent 51 2>&1 | Out-Null + $LASTEXITCODE | Should -Be 1 + } } diff --git a/build/Assert-Coverage.ps1 b/build/Assert-Coverage.ps1 index 3fb966b..418f2ba 100644 --- a/build/Assert-Coverage.ps1 +++ b/build/Assert-Coverage.ps1 @@ -9,7 +9,12 @@ can be split across test projects, and the report that exercises it most is authoritative. Without -Packages, every package the reports measured is gated: coverage.runsettings already limits measurement to the assemblies held to the bar. - Exits 1 on any shortfall, or when a package named in -Packages is missing. + Exits 1 on any shortfall, when a package named in -Packages is missing, or when no + report measured a single line of it. + + Every report under the directory counts, including one left by an earlier run, so + collect into an empty directory. Run the script as its own process + (pwsh -NoProfile -File): called in-process, its exit code does not stop the caller. .EXAMPLE ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults @@ -37,10 +42,25 @@ if ($files.Count -eq 0) exit 1 } -$reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) } +# One entry per package per report: where it was measured, how many lines, and how well. +$measured = foreach ($file in $files) +{ + $document = [xml](Get-Content -LiteralPath $file.FullName -Raw) + foreach ($package in $document.SelectNodes('/coverage/packages/package')) + { + [pscustomobject]@{ + Name = $package.GetAttribute('name') + Report = $file.FullName + Lines = $package.SelectNodes('classes/class/lines/line').Count + Rate = [double]::Parse($package.GetAttribute('line-rate'), [Globalization.CultureInfo]::InvariantCulture) + Package = $package + } + } +} +$measured = @($measured) if ($Packages.Count -eq 0) { - $Packages = @($reports | ForEach-Object { @($_.coverage.packages.package) } | ForEach-Object { $_.name } | Where-Object { $_ } | Sort-Object -Unique) + $Packages = @($measured | ForEach-Object { $_.Name } | Where-Object { $_ } | Sort-Object -Unique) if ($Packages.Count -eq 0) { Write-Error 'The coverage reports measured no packages; check the Include filter in coverage.runsettings.' -ErrorAction Continue @@ -52,30 +72,31 @@ $failed = $false foreach ($name in $Packages) { - $rates = foreach ($doc in $reports) - { - $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } - if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) } - } - if (-not $rates) + $found = @($measured | Where-Object { $_.Name -eq $name }) + if ($found.Count -eq 0) { Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue $failed = $true continue } - $best = ($rates | Measure-Object -Maximum).Maximum - Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent) - if ($best -lt $required) + # A package with no measured lines reports a perfect rate; it proves nothing. + $counted = @($found | Where-Object { $_.Lines -gt 0 }) + if ($counted.Count -eq 0) { - foreach ($doc in $reports) + Write-Error "Package '$name' has no measured lines in any coverage report." -ErrorAction Continue + $failed = $true + continue + } + + $best = $counted | Sort-Object -Property Rate -Descending | Select-Object -First 1 + Write-Output ("{0} line coverage: {1}% of {2} lines (required {3}%), from {4}" -f $name, [math]::Round($best.Rate * 100, 2), $best.Lines, $MinimumPercent, $best.Report) + if ($best.Rate -lt $required) + { + foreach ($class in $best.Package.SelectNodes('classes/class')) { - $package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name } - foreach ($class in @($package.classes.class)) - { - $missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number } - if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" } - } + $missed = @($class.SelectNodes('lines/line[@hits="0"]') | ForEach-Object { $_.GetAttribute('number') }) + if ($missed.Count -gt 0) { Write-Output " uncovered: $($class.GetAttribute('filename')) lines $($missed -join ', ')" } } Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue $failed = $true diff --git a/coverage.runsettings b/coverage.runsettings index d866b62..6f30235 100644 --- a/coverage.runsettings +++ b/coverage.runsettings @@ -1,9 +1,8 @@ - true diff --git a/src/MarkdownViewr.Windows.Core/packages.lock.json b/src/MarkdownViewr.Windows.Core/packages.lock.json index 25cb5ba..2ffde4a 100644 --- a/src/MarkdownViewr.Windows.Core/packages.lock.json +++ b/src/MarkdownViewr.Windows.Core/packages.lock.json @@ -30,12 +30,6 @@ "resolved": "3.0.296", "contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg==" }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.14, )", - "resolved": "10.0.14", - "contentHash": "YlTamXppEQls6DYWt7RdUuwWDb0lyw/vXCaGeQpLCpsLmfwGuCkOz/vIGgMTV7ECsJcavkCOkxZXv9s8rdAHkQ==" - }, "AngleSharp": { "type": "Transitive", "resolved": "1.7.2", diff --git a/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs b/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs index a427b26..6cb885c 100644 --- a/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs +++ b/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs @@ -28,16 +28,20 @@ internal sealed class TempDirectory : IDisposable - /// Deletes the directory; a handle left open by a failed test is left for the OS. + /// + /// Deletes the directory, and the shared parent once no other test is using it. A handle left + /// open by a failed test keeps its folder: cleanup must not replace the failure being reported. + /// public void Dispose() { try { Directory.Delete(Path, recursive: true); + Directory.Delete(System.IO.Path.GetDirectoryName(Path)!); } catch(IOException) { - // Cleanup only; the test outcome is already decided. + // The folder is in use, or the parent still holds another test's folder. } } } diff --git a/tests/MarkdownViewr.Windows.UiTests/AppTests.cs b/tests/MarkdownViewr.Windows.UiTests/AppTests.cs index 60c29d2..796719d 100644 --- a/tests/MarkdownViewr.Windows.UiTests/AppTests.cs +++ b/tests/MarkdownViewr.Windows.UiTests/AppTests.cs @@ -20,6 +20,7 @@ namespace MarkdownViewr.Windows.UiTests; /// UI tests share one desktop, so they never run in parallel. /// [Collection("Desktop")] +[Trait("Category", "Desktop")] public partial class AppTests { private readonly ITestOutputHelper _output; diff --git a/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs b/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs index bdadf4d..17af737 100644 --- a/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs +++ b/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs @@ -11,7 +11,8 @@ namespace MarkdownViewr.Windows.UiTests; /// Registry adapters operate beneath an isolated UUID subtree, never the user's real Classes/default-app keys. internal sealed class RegistryFixture : IDisposable { - private readonly string _path = "Software\\KofTwentyTwo\\MarkdownViewr.Windows.Tests\\" + Guid.NewGuid().ToString("N"); + private const string Parent = "Software\\KofTwentyTwo\\MarkdownViewr.Windows.Tests"; + private readonly string _path = Parent + "\\" + Guid.NewGuid().ToString("N"); @@ -25,10 +26,18 @@ internal sealed class RegistryFixture : IDisposable - /// Deletes only the fixed-prefix UUID subtree created by this fixture. + /// Deletes the UUID subtree created by this fixture, and the shared parent key once no other fixture has a subtree under it. public void Dispose() { Root.Dispose(); Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false); + try + { + Registry.CurrentUser.DeleteSubKey(Parent, throwOnMissingSubKey: false); + } + catch(InvalidOperationException) + { + // Another fixture still has a subtree under the parent. + } } } -- 2.54.0 From 1b47a0a07a8456b40d4e055b14b339a94bf23093 Mon Sep 17 00:00:00 2001 From: James Maes Date: Tue, 10 Nov 2026 19:03:52 -0600 Subject: [PATCH 2/2] ci: count coverage from the lines and fail a fuzz filter that matches nothing The coverage gate now counts covered lines itself instead of reading the report's summary rate, matches package names exactly, and shows the percentage rounded down. Its tests cover reports whose stated rate disagrees with their lines, several packages, and several reports. The long fuzz campaigns run only on main and tags, where they differ from what the ordinary test run already did, decide that from the ref, print the iteration count, and fail when their filter matches no test. Test helpers no longer remove the temp folder and registry key that all test processes share: a second process could be creating its own entry there at that moment. AGENTS.md, the fuzzing assessment and the installed-update guide describe what runs today. Refs #54 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- .gitea/workflows/build.yaml | 14 +- AGENTS.md | 7 +- CONTRIBUTING.md | 2 +- DEVELOPERS.md | 2 +- build/Assert-Coverage.Tests.ps1 | 163 ++++++++++++------ build/Assert-Coverage.ps1 | 49 ++++-- docs/RELEASING.md | 6 +- docs/installed-updates.md | 4 +- docs/security/assurance.json | 2 +- docs/security/fuzzing.md | 13 +- .../MarkdownViewr.Windows.Core.Tests/Fakes.cs | 8 +- .../RegistryFixture.cs | 13 +- 12 files changed, 178 insertions(+), 105 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 89a7172..8655884 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -22,11 +22,15 @@ jobs: # Windows adapters (files, watcher, registry) against the real operating system. The Desktop # category drives the app with mouse and keyboard and needs an interactive session. dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category!=Desktop" --logger trx --results-directory TestResults - # Seeded parser campaigns: a longer run on main and on tags than on topic branches. - $env:K22_FUZZ_ITERATIONS = ($env:GITHUB_REF_NAME -eq 'main' -or $env:GITHUB_REF_TYPE -eq 'tag') ? '10000' : '500' - dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --filter "Category=Fuzz" - dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Fuzz" - Remove-Item Env:K22_FUZZ_ITERATIONS + # The runs above include the seeded parser campaigns at 500 iterations. Main and tags repeat + # them at 10000; a filter that matches no test fails, so a renamed category cannot pass unseen. + if ($env:GITHUB_REF -eq 'refs/heads/main' -or $env:GITHUB_REF -like 'refs/tags/*') { + $env:K22_FUZZ_ITERATIONS = '10000' + Write-Output "Parser campaigns at $env:K22_FUZZ_ITERATIONS iterations for $env:GITHUB_REF" + dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --filter "Category=Fuzz" -- RunConfiguration.TreatNoTestsAsError=true + dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Fuzz" -- RunConfiguration.TreatNoTestsAsError=true + Remove-Item Env:K22_FUZZ_ITERATIONS + } # PowerShell build scripts: analyzer and Pester boundary tests with pinned modules saved to the job's temp directory. $modules = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "psmodules-$env:GITHUB_RUN_ID" New-Item -ItemType Directory -Force -Path $modules | Out-Null diff --git a/AGENTS.md b/AGENTS.md index 92994b2..e2f60fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,12 +13,13 @@ Use Windows and the .NET 10 SDK selected by `global.json`: ```powershell dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror -dotnet test tests/MarkdownViewr.Windows.Core.Tests --settings coverage.runsettings --collect:'XPlat Code Coverage' --results-directory TestResults -./build/Assert-Coverage.ps1 -ResultsDirectory TestResults +dotnet test tests/MarkdownViewr.Windows.Core.Tests --no-build --settings coverage.runsettings --collect:'XPlat Code Coverage' --results-directory TestResults # TestResults must start empty +pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core +dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category!=Desktop" dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore ``` -Desktop tests require an explicitly available interactive desktop. Run `dotnet test tests/MarkdownViewr.Windows.UiTests` only when input will not interfere with other work. Preserve 100% measured core coverage; use xUnit and deterministic injected time/filesystem boundaries. Follow `Method_Scenario_Expectation` test names. +Desktop tests require an explicitly available interactive desktop. Run `dotnet test tests/MarkdownViewr.Windows.UiTests --no-build --filter "Category=Desktop"` only when input will not interfere with other work. Preserve 100% measured core coverage; use xUnit and deterministic injected time/filesystem boundaries. Follow `Method_Scenario_Expectation` test names. ## Style and Contributions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8125346..013e0d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ This Product follows the [KofTwentyTwo standards](https://github.com/KofTwentyTw - Preserve original MIT notices and dependency credits. - Disclose substantial AI assistance and include a `Co-Authored-By:` trailer naming the model. -CI runs on every push and covers the build, formatting, core tests with the coverage gate, Windows adapter tests, parser fuzzing, and tooling tests. Desktop and installed-update acceptance, the dependency, secret, and workflow scans, and CodeQL are not automated at present; [DEVELOPERS.md](DEVELOPERS.md#what-runs-where) lists what to run by hand. Local hooks check the commit title, the sign-off, and secrets. +CI runs on every push and covers the build, formatting, core tests with the coverage gate, Windows adapter tests, parser fuzzing, and tooling tests. It also verifies npm registry signatures, and the NuGet restore reports known vulnerable packages. Desktop and installed-update acceptance, the secret and workflow scans, and CodeQL are not automated at present; [DEVELOPERS.md](DEVELOPERS.md#what-runs-where) lists what to run by hand. Local hooks check the commit title, the sign-off, and secrets. Under the single-maintainer policy, `review / automated` also requires a genuine current-commit acknowledgement from the configured human maintainer. An agent cannot provide that acknowledgement. See [repository setup](docs/repository-setup.md). diff --git a/DEVELOPERS.md b/DEVELOPERS.md index c2ebc5b..1bf5dc6 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -61,7 +61,7 @@ The workflows under `.github/` belong to the mirror, where GitHub Actions is cur - the `Desktop` tests, against a local build and against published output; - [installed-update acceptance](docs/installed-updates.md): actual stable and development installation, update and restart, registration, and coexistence. Its synthetic unsigned fixtures do not establish production signing; -- secret, dependency, and workflow scans, and CodeQL. +- secret and workflow scans, and CodeQL. Run the [seeded parser campaigns](docs/security/fuzzing.md) when changing parser or activation boundaries. Never use private documents as a corpus. diff --git a/build/Assert-Coverage.Tests.ps1 b/build/Assert-Coverage.Tests.ps1 index e296e6e..0fd22ca 100644 --- a/build/Assert-Coverage.Tests.ps1 +++ b/build/Assert-Coverage.Tests.ps1 @@ -12,28 +12,42 @@ BeforeAll ` <# .SYNOPSIS - Writes a synthetic report so tests can exercise release-gate exit codes. + Writes a synthetic report for one package: one line per entry of -Hits, in one class. #> function Write-CoverageReport { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Directory, - [Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate, - [switch] $NoLines + [string] $Package = 'Probe', + [int[]] $Hits = @(1, 1), + # What the report claims in its summary attribute; the gate must not believe it. + [string] $StatedRate = '1' ) - $rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture) - $secondLineHits = if ($Rate -eq 1) { 1 } else { 0 } - $lines = if ($NoLines) { '' } else { "" } + $number = 0 + $lines = foreach ($count in $Hits) { $number++; "" } $report = @" - -$lines + +$($lines -join '') "@ New-Item -ItemType Directory -Force -Path $Directory | Out-Null Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report } + + <# + .SYNOPSIS + Runs the gate as CI does, in its own process, and returns its exit code and combined output. + #> + function Invoke-CoverageGate + { + [CmdletBinding()] + param([Parameter(ValueFromRemainingArguments)] [string[]] $Arguments) + + $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory @Arguments 2>&1) -join "`n" + [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } } Describe 'Required coverage gate' ` @@ -46,75 +60,128 @@ Describe 'Required coverage gate' ` It 'rejects a run that produced no report' ` { - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'No coverage.cobertura.xml' + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'No coverage.cobertura.xml' } It 'rejects an absent required package' ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Missing 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'Missing.*not found' + Write-CoverageReport -Directory $script:ReportsDirectory + $result = Invoke-CoverageGate -Packages Missing + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match "'Missing' not found" } - It 'rejects coverage below the required percentage' ` + It 'does not take a longer or differently cased package name for the required one' ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'Coverage gate FAILED' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'longer') -Package 'ProbeExtra' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'cased') -Package 'probe' + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match "'Probe' not found" + } + + It 'rejects coverage below the required percentage and names the uncovered lines' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Hits 1, 0, 1, 0 + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Probe line coverage: 50% \(2 of 4 lines' + $result.Output | Should -Match 'uncovered: Probe\.cs lines 2, 4(\s|$)' + $result.Output | Should -Match 'Coverage gate FAILED' } It 'accepts full coverage of the required package' ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 0 - $output | Should -Match 'Coverage gate passed' + Write-CoverageReport -Directory $script:ReportsDirectory + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match 'Probe line coverage: 100% \(2 of 2 lines' + $result.Output | Should -Match 'Coverage gate passed' } - It 'names the uncovered lines of a package below the bar' ` + It 'counts the lines itself instead of believing the stated rate ' -ForEach @( + @{ StatedRate = '1' } + @{ StatedRate = '1.5' } + @{ StatedRate = 'NaN' } + @{ StatedRate = '0,5' } + ) ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'uncovered: Probe\.cs lines 2(\s|$)' + Write-CoverageReport -Directory $script:ReportsDirectory -Hits 1, 0 -StatedRate $StatedRate + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Probe line coverage: 50% \(1 of 2 lines' + } + + It 'never shows a package just under the bar as having reached it' ` + { + Write-CoverageReport -Directory $script:ReportsDirectory -Hits (@(1) * 19999 + 0) + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Probe line coverage: 99\.99% \(19999 of 20000 lines' } It 'rejects a package no report measured a line of' ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1 -NoLines - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'no measured lines' + Write-CoverageReport -Directory $script:ReportsDirectory -Hits @() + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'no measured lines' } It 'ignores an empty report when another test project measured the package' ` { - Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Rate 1 -NoLines - Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Rate 0.5 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 1 - $output | Should -Match 'Probe line coverage: 50% of 2 lines' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Hits @() + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Hits 1, 0 + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Probe line coverage: 50% \(1 of 2 lines' } It 'takes the test project that covers the package best' ` { - Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Rate 0.5 - Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Rate 1 - $output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory 2>&1) -join "`n" - $LASTEXITCODE | Should -Be 0 - $output | Should -Match 'Probe line coverage: 100% of 2 lines .*second' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Hits 1, 0 + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Hits 1, 1 + $result = Invoke-CoverageGate + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match 'Probe line coverage: 100% \(2 of 2 lines.*second' + } + + It 'lists uncovered lines only from the report it used' ` + { + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Hits 0, 0, 0, 1 + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Hits 1, 1, 0, 1 + $result = Invoke-CoverageGate -Packages Probe + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Probe line coverage: 75% \(3 of 4 lines.*second' + $result.Output | Should -Match 'uncovered: Probe\.cs lines 3(\s|$)' + $result.Output | Should -Not -Match 'lines 1, 2, 3' + } + + It 'gates every package named in one comma-separated argument' ` + { + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Package 'First' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Package 'Second' -Hits 1, 0 + $result = Invoke-CoverageGate -Packages 'First,Second' + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'First line coverage: 100%' + $result.Output | Should -Match 'Coverage gate FAILED: Second' + } + + It 'gates every measured package when none is named' ` + { + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'first') -Package 'First' + Write-CoverageReport -Directory (Join-Path $script:ReportsDirectory 'second') -Package 'Second' -Hits 1, 0 + $result = Invoke-CoverageGate + $result.ExitCode | Should -Be 1 + $result.Output | Should -Match 'Coverage gate FAILED: Second' } It 'holds the package to a lower bar when one is given' ` { - Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5 - & pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -MinimumPercent 50 | Out-Null - $LASTEXITCODE | Should -Be 0 - & pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -MinimumPercent 51 2>&1 | Out-Null - $LASTEXITCODE | Should -Be 1 + Write-CoverageReport -Directory $script:ReportsDirectory -Hits 1, 0 + (Invoke-CoverageGate -MinimumPercent 50).ExitCode | Should -Be 0 + (Invoke-CoverageGate -MinimumPercent 51).ExitCode | Should -Be 1 } } diff --git a/build/Assert-Coverage.ps1 b/build/Assert-Coverage.ps1 index 418f2ba..b96c7d4 100644 --- a/build/Assert-Coverage.ps1 +++ b/build/Assert-Coverage.ps1 @@ -1,26 +1,32 @@ #Requires -Version 7.4 +# Copyright (c) 2026 James Maes +# SPDX-License-Identifier: MIT <# .SYNOPSIS Fails when any named assembly is below the required line coverage. .DESCRIPTION Reads every coverage.cobertura.xml under -ResultsDirectory (one per test project - run) and, for each package, takes the best line rate any report measured. A package - can be split across test projects, and the report that exercises it most is - authoritative. Without -Packages, every package the reports measured is gated: - coverage.runsettings already limits measurement to the assemblies held to the bar. + run) and, for each package, takes the report that covers it best. A package can be + split across test projects, and the report that exercises it most is authoritative. + Without -Packages, every package the reports measured is gated: coverage.runsettings + already limits measurement to the assemblies held to the bar. + + The rate is counted from the lines each report lists, not read from its summary + attributes, so a report cannot state a rate its lines do not support. + Exits 1 on any shortfall, when a package named in -Packages is missing, or when no report measured a single line of it. Every report under the directory counts, including one left by an earlier run, so - collect into an empty directory. Run the script as its own process - (pwsh -NoProfile -File): called in-process, its exit code does not stop the caller. + collect into an empty directory. Run the script as its own process: called + in-process, its exit code does not stop the caller. .EXAMPLE - ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults + pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults .EXAMPLE - ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core + pwsh -NoProfile -File ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core #> [CmdletBinding()] param( @@ -42,17 +48,23 @@ if ($files.Count -eq 0) exit 1 } -# One entry per package per report: where it was measured, how many lines, and how well. +# One entry per package per report: where it was measured, how many lines, and how many of them ran. $measured = foreach ($file in $files) { $document = [xml](Get-Content -LiteralPath $file.FullName -Raw) foreach ($package in $document.SelectNodes('/coverage/packages/package')) { + $lines = $package.SelectNodes('classes/class/lines/line') + $covered = 0 + foreach ($line in $lines) + { + if ([int]::Parse($line.GetAttribute('hits'), [Globalization.CultureInfo]::InvariantCulture) -gt 0) { $covered++ } + } [pscustomobject]@{ Name = $package.GetAttribute('name') Report = $file.FullName - Lines = $package.SelectNodes('classes/class/lines/line').Count - Rate = [double]::Parse($package.GetAttribute('line-rate'), [Globalization.CultureInfo]::InvariantCulture) + Lines = $lines.Count + Covered = $covered Package = $package } } @@ -67,12 +79,11 @@ if ($Packages.Count -eq 0) exit 1 } } -$required = $MinimumPercent / 100 $failed = $false foreach ($name in $Packages) { - $found = @($measured | Where-Object { $_.Name -eq $name }) + $found = @($measured | Where-Object { $_.Name -ceq $name }) if ($found.Count -eq 0) { Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue @@ -80,7 +91,7 @@ foreach ($name in $Packages) continue } - # A package with no measured lines reports a perfect rate; it proves nothing. + # A package with no measured lines proves nothing. $counted = @($found | Where-Object { $_.Lines -gt 0 }) if ($counted.Count -eq 0) { @@ -89,9 +100,13 @@ foreach ($name in $Packages) continue } - $best = $counted | Sort-Object -Property Rate -Descending | Select-Object -First 1 - Write-Output ("{0} line coverage: {1}% of {2} lines (required {3}%), from {4}" -f $name, [math]::Round($best.Rate * 100, 2), $best.Lines, $MinimumPercent, $best.Report) - if ($best.Rate -lt $required) + # Best covered share first; between equal shares, the report that measured more lines. + $best = $counted | Sort-Object -Property @{ Expression = { $_.Covered / $_.Lines }; Descending = $true }, @{ Expression = { $_.Lines }; Descending = $true } | Select-Object -First 1 + $percent = 100 * $best.Covered / $best.Lines + # Rounded down, so a package just under the bar is never shown as having reached it. + $shown = [math]::Floor($percent * 100) / 100 + Write-Output ("{0} line coverage: {1}% ({2} of {3} lines, required {4}%), from {5}" -f $name, $shown, $best.Covered, $best.Lines, $MinimumPercent, $best.Report) + if ($percent -lt $MinimumPercent) { foreach ($class in $best.Package.SelectNodes('classes/class')) { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 8448350..b87c3d2 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -8,9 +8,9 @@ which calls the shared `release-velopack.yml` workflow in KofTwentyTwo/standards ## Current state The repository moved to Gitea, and GitHub is now a mirror with GitHub Actions switched -off. The release workflow below exists only on GitHub, so until Actions is switched on -there, or a signing release job exists on Gitea, pushing a `v*` tag builds and tests the -tag on Gitea and publishes nothing. Do not tag expecting a release. +off. The release workflow this document describes exists only on GitHub, so until a +signing release job exists on Gitea, pushing a `v*` tag builds and tests the tag on +Gitea and publishes nothing. Do not tag expecting a release. ## Workflow baseline diff --git a/docs/installed-updates.md b/docs/installed-updates.md index 1fd8e6a..0db40bb 100644 --- a/docs/installed-updates.md +++ b/docs/installed-updates.md @@ -77,8 +77,8 @@ checks do not establish the corruption's cause. ## Evidence and cleanup -CI requires `installed-updates` and retains JSON results, process/installer logs -and TRX UI reports. Local evidence stays under a fresh `artifacts/installed-smoke-*` +The harness has no automated run at present; run it by hand before a release. It +writes JSON results, process/installer logs and TRX UI reports. Local evidence stays under a fresh `artifacts/installed-smoke-*` directory. Existing output directories are refused so each attempt tests fresh packages and results. Packer children receive a private `VELOPACK_TEMP` directory named for the unique test package ID, isolating staging from other app builds. diff --git a/docs/security/assurance.json b/docs/security/assurance.json index 3a35dac..44f0fa8 100644 --- a/docs/security/assurance.json +++ b/docs/security/assurance.json @@ -14,7 +14,7 @@ "tests/MarkdownViewr.Windows.UiTests/ActivationFuzzTests.cs" ], "command": "dotnet test tests/MarkdownViewr.Windows.Core.Tests -c Release -p:Platform=x64 --no-build --no-restore --filter Category=Fuzz", - "workflow": ".github/workflows/fuzz.yml", + "workflow": ".gitea/workflows/build.yaml", "evidence": "docs/security/fuzzing.md" } } diff --git a/docs/security/fuzzing.md b/docs/security/fuzzing.md index fd735b2..22fdbe5 100644 --- a/docs/security/fuzzing.md +++ b/docs/security/fuzzing.md @@ -35,13 +35,12 @@ dotnet test tests/MarkdownViewr.Windows.UiTests -c Release -p:Platform=x64 --fil $env:K22_FUZZ_ITERATIONS = '10000' ``` -Each target uses seeds 22023 and 99173. PR/main campaigns run 500 iterations per -seed/target (5000 generated property evaluations total); weekly and every release -reuse run 10000 (100000 total). `fuzz.yml` runs on PRs, `main`, weekly and manual -requests; release.yml calls it and requires success before publication. The -`fuzzing` check is declared in the repository protection plan; apply and verify -that live protection after its first hosted run. The job has a 15-minute bound; -iteration configuration outside 1–100000 fails explicitly. +Each target uses seeds 22023 and 99173. Every push runs 500 iterations per +seed/target (5000 generated property evaluations total) as part of the ordinary +test runs in `.gitea/workflows/build.yaml`. Pushes to `main` and to a tag repeat +the campaigns at 10000 (100000 total), and that step fails if its filter matches +no test. There is no scheduled campaign. Iteration configuration outside 1–100000 +fails explicitly. Failures retain seed, target, zero-based iteration and JSON-escaped synthetic input in TRX/CI logs. To replay the sequence, use the same checkout/runtime/seed diff --git a/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs b/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs index 6cb885c..a427b26 100644 --- a/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs +++ b/tests/MarkdownViewr.Windows.Core.Tests/Fakes.cs @@ -28,20 +28,16 @@ internal sealed class TempDirectory : IDisposable - /// - /// Deletes the directory, and the shared parent once no other test is using it. A handle left - /// open by a failed test keeps its folder: cleanup must not replace the failure being reported. - /// + /// Deletes the directory; a handle left open by a failed test is left for the OS. public void Dispose() { try { Directory.Delete(Path, recursive: true); - Directory.Delete(System.IO.Path.GetDirectoryName(Path)!); } catch(IOException) { - // The folder is in use, or the parent still holds another test's folder. + // Cleanup only; the test outcome is already decided. } } } diff --git a/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs b/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs index 17af737..bdadf4d 100644 --- a/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs +++ b/tests/MarkdownViewr.Windows.UiTests/RegistryFixture.cs @@ -11,8 +11,7 @@ namespace MarkdownViewr.Windows.UiTests; /// Registry adapters operate beneath an isolated UUID subtree, never the user's real Classes/default-app keys. internal sealed class RegistryFixture : IDisposable { - private const string Parent = "Software\\KofTwentyTwo\\MarkdownViewr.Windows.Tests"; - private readonly string _path = Parent + "\\" + Guid.NewGuid().ToString("N"); + private readonly string _path = "Software\\KofTwentyTwo\\MarkdownViewr.Windows.Tests\\" + Guid.NewGuid().ToString("N"); @@ -26,18 +25,10 @@ internal sealed class RegistryFixture : IDisposable - /// Deletes the UUID subtree created by this fixture, and the shared parent key once no other fixture has a subtree under it. + /// Deletes only the fixed-prefix UUID subtree created by this fixture. public void Dispose() { Root.Dispose(); Registry.CurrentUser.DeleteSubKeyTree(_path, throwOnMissingSubKey: false); - try - { - Registry.CurrentUser.DeleteSubKey(Parent, throwOnMissingSubKey: false); - } - catch(InvalidOperationException) - { - // Another fixture still has a subtree under the parent. - } } } -- 2.54.0