From bcc4e2420a03e7146d2652dc598965ae2730c2f9 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 3 Jul 2026 14:57:01 -0400 Subject: [PATCH 1/3] Stand up code coverage tooling and mandate it in review skills The existing /EnableCodeCoverage MSBuild flag (Build/SetupInclude.targets) depends on a VS-Enterprise-only "Code Coverage" data collector and silently fails to collect on Community/Build Tools installs (confirmed live: vstest.console.exe reports "Unable to find a datacollector"). Add coverlet.collector instead, which registers the cross-platform "XPlat Code Coverage" collector and works under plain vstest.console.exe on any VS edition. Wire a `-Coverage` switch into test.ps1 that collects coverage (per-project TestAdapterPath resolved from the centrally-pinned package version, same pattern as the existing NUnit adapter lookup) and renders a summary/HTML report via a local ReportGenerator dotnet tool (.config/dotnet-tools.json). Add the fieldworks-test-coverage skill (how to check that new/changed lines are actually exercised, not just that a test file exists nearby) and reference it from execute-implement/verify-test and the base rubric's test_coverage criterion. Co-Authored-By: Claude Sonnet 5 --- .claude/skills/execute-implement/SKILL.md | 1 + .../skills/fieldworks-test-coverage/SKILL.md | 43 ++++++++++ .claude/skills/verify-test/SKILL.md | 2 + .config/dotnet-tools.json | 13 +++ .github/rubrics/fieldworks-rubric.base.yaml | 4 +- Directory.Packages.props | 4 + Src/Directory.Build.props | 1 + test.ps1 | 86 +++++++++++++++++++ 8 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/fieldworks-test-coverage/SKILL.md create mode 100644 .config/dotnet-tools.json diff --git a/.claude/skills/execute-implement/SKILL.md b/.claude/skills/execute-implement/SKILL.md index 3d33158ffa..9c9b1fed1f 100644 --- a/.claude/skills/execute-implement/SKILL.md +++ b/.claude/skills/execute-implement/SKILL.md @@ -24,6 +24,7 @@ You will receive: - Apply the minimal change set needed to satisfy acceptance signals. 3. **Self-check** - Review for style, safety, and regression risk. + - Every new or changed decision/branch needs at least one test that actually exercises it — use `fieldworks-test-coverage` to verify with `test.ps1 -Coverage`, not just "a test file exists nearby." Document any accepted gap (e.g. requires a live/manual round-trip) rather than letting it pass silently. - For FieldWorks branches that will become PRs, use the review policy in `.github/instructions/review-analyzer.instructions.md` or run `pr-preflight` before handoff when risk or scope warrants it. 4. **Update status** - Record what changed and any follow-up notes. diff --git a/.claude/skills/fieldworks-test-coverage/SKILL.md b/.claude/skills/fieldworks-test-coverage/SKILL.md new file mode 100644 index 0000000000..9bb45786d8 --- /dev/null +++ b/.claude/skills/fieldworks-test-coverage/SKILL.md @@ -0,0 +1,43 @@ +--- +name: fieldworks-test-coverage +description: Check that new or changed FieldWorks code is actually exercised by tests, using test.ps1 -Coverage. Use whenever writing tests for a change, before claiming a bug fix or feature is covered, or when reviewing whether "tests pass" also means the new logic runs under test. +model: haiku +--- + + +You confirm that new/changed behavior is exercised by tests, not merely that some test file exists nearby. "Tests pass" is not the same claim as "the new code ran during the test" — this skill closes that gap. + + + +You will receive: +- The diff or file list for the change +- The test project(s) that build against the changed files + + + +1. **Identify the decision surface** + - For each changed method, find the new/changed branches, guards, and edge cases — not just "a test file touches this class." + - Pure decisions (parsing, gating, mapping) are cheap to cover directly. Side-effecting code wrapped around registry/filesystem/global-singleton calls is often already excluded by design in this repo (e.g. `AvaloniaOptionsDialogLauncher.Apply`) — check whether the surrounding test file already documents that scope boundary before assuming it's a gap. +2. **Run coverage for the touched project(s)** + - `.\test.ps1 -TestProject -Coverage [-NoBuild] [-TestFilter "..."]` + - This collects via coverlet.collector's "XPlat Code Coverage" data collector (works under plain vstest.console.exe on any VS edition — the older `/EnableCodeCoverage` flag in `Build/SetupInclude.targets` requires VS Enterprise and will silently fail to collect on Community/Build Tools installs). + - Reads the merged report from `Output//TestResults/CoverageReport/Summary.txt` (and `index.html` for line-by-line detail) that the script prints automatically. +3. **Check the new lines specifically** + - Open `index.html` for the changed file(s) and confirm the new/changed lines show as covered (green), not just that the file's overall percentage moved. + - A changed file can show high coverage while the new branch itself is dead — always check the specific lines, not the file average. +4. **Close real gaps, document accepted ones** + - If a new decision is uncovered and cheaply testable, write the test (see repo test file conventions — extract pure decisions into small static/testable methods when the surrounding code is otherwise hard to unit test, as with `AvaloniaDialogHost.ResolveEffectiveOwner`). + - If a gap is accepted (e.g. it requires a live/manual/desktop-only round-trip), say so explicitly in the PR description or handoff — do not let it pass silently as "tests pass." + + + +- Never report coverage as a percentage alone; tie the claim to the specific new/changed lines. +- Do not chase repo-wide coverage — scope to the current change's touched files. +- Prefer extracting a testable pure decision over mocking heavy framework/registry/filesystem dependencies to force coverage of an untestable method. + + + +- See `verify-test` for the broader build/test verification flow this slots into. +- See `execute-implement`'s self-check step, which references this skill for new logic. +- `test.ps1 -Coverage` requires the local dotnet tool manifest to be restored once per checkout: `dotnet tool restore` (installs ReportGenerator, used to render the summary/HTML from the raw `coverage.cobertura.xml`). + diff --git a/.claude/skills/verify-test/SKILL.md b/.claude/skills/verify-test/SKILL.md index 82cead5710..27c8cf7296 100644 --- a/.claude/skills/verify-test/SKILL.md +++ b/.claude/skills/verify-test/SKILL.md @@ -23,6 +23,7 @@ You will receive: - Execute builds/tests or manual checks as appropriate. - For FieldWorks, run `./build.ps1 -Clean` before validation when switching branches or worktrees, upgrading package versions, suspecting stale `Obj/` or `Output/` artifacts, or any time you need a fully clean validation baseline. - After the clean step, rerun the normal scripted verification commands such as `./build.ps1`, `./test.ps1`, or the narrow scripted slice you are validating. + - When the change adds or edits behavior (not docs/test-only changes), also run `./test.ps1 -Coverage` for the touched project(s) — see `fieldworks-test-coverage` — and confirm the new/changed lines are actually exercised, not just that the suite passes. 3. **Capture results** - Record pass/fail outcomes and any artifacts. 4. **Report** @@ -38,4 +39,5 @@ You will receive: - Use FieldWorks build/test scripts when applicable. - Use `rubric-verify` when execution-free scoring or explicit hard-gate review is requested. +- Use `fieldworks-test-coverage` to check that new/changed lines are exercised, not just that tests pass. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000000..dbcca1d66f --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.3.11", + "commands": [ + "reportgenerator" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.github/rubrics/fieldworks-rubric.base.yaml b/.github/rubrics/fieldworks-rubric.base.yaml index db175ed181..391cb20759 100644 --- a/.github/rubrics/fieldworks-rubric.base.yaml +++ b/.github/rubrics/fieldworks-rubric.base.yaml @@ -74,8 +74,8 @@ categories: evidence: API diff or behavioral comparison note - id: test_coverage weight: 0.20 - check: New or changed behavior has at least one corresponding test. - evidence: test names covering the change + check: New or changed behavior has at least one corresponding test that actually exercises the new/changed lines (not merely a nearby test file). + evidence: test names covering the change, ideally cross-checked with `test.ps1 -Coverage` (see `fieldworks-test-coverage`) - id: integrity weight: 0.25 diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f8810af03..3803a44b3b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -164,6 +164,10 @@ + + diff --git a/Src/Directory.Build.props b/Src/Directory.Build.props index 2d546ecf9f..01ddb76415 100644 --- a/Src/Directory.Build.props +++ b/Src/Directory.Build.props @@ -35,4 +35,5 @@ + diff --git a/test.ps1 b/test.ps1 index 864e4ba1c5..ca7db3fb65 100644 --- a/test.ps1 +++ b/test.ps1 @@ -44,6 +44,13 @@ Internal switch used when test.ps1 is invoked from build.ps1 -RunTests. Skips acquiring/releasing the same-worktree lock because the parent build already owns it. +.PARAMETER Coverage + Collect code coverage via the coverlet.collector "XPlat Code Coverage" data collector (works under + plain vstest.console.exe on any Visual Studio edition, unlike the VS-Enterprise-only "Code Coverage" + collector). Writes one coverage.cobertura.xml per test host under Output//TestResults, + then renders a merged summary + HTML report via the local ReportGenerator tool + (Output//TestResults/CoverageReport). + .EXAMPLE .\test.ps1 Runs all tests in Debug configuration (builds first if needed). @@ -69,6 +76,10 @@ .\test.ps1 -SkipManaged -TestProject TestGeneric Uses the environment variable equivalent of -AllowAssertDialogs. Clear the variable after debugging. +.EXAMPLE + .\test.ps1 -TestProject "Src/Common/FwAvalonia/FwAvaloniaTests" -Coverage + Runs FwAvaloniaTests with code coverage collection and prints a coverage summary. + .NOTES FieldWorks is x64-only. Tests run in 64-bit mode. #> @@ -86,6 +97,7 @@ param( [switch]$AllowAssertDialogs, [switch]$SkipDependencyCheck, [switch]$SkipWorktreeLock, + [switch]$Coverage, [ValidateSet('user', 'agent', 'unknown')] [string]$StartedBy = 'unknown' ) @@ -280,6 +292,28 @@ function Get-NUnitTestAdapterPaths { return $adapterPaths.ToArray() } +function Get-CoverageAdapterPath { + param( + [string]$RepoRoot + ) + + # vstest.console.exe (unlike `dotnet test`) does not auto-discover data collectors from NuGet + # packages; the coverlet.collector build folder must be passed explicitly via /TestAdapterPath, + # same as the NUnit adapter above. + $packagesPropsPath = Join-Path $RepoRoot 'Directory.Packages.props' + $version = Get-CentralPackageVersion -PackagesPropsPath $packagesPropsPath -PackageName 'coverlet.collector' + if ([string]::IsNullOrWhiteSpace($version)) { + return $null + } + + $adapterPath = Join-Path $RepoRoot "packages/coverlet.collector/$version/build/netstandard2.0" + if (Test-Path -LiteralPath (Join-Path $adapterPath 'coverlet.collector.dll') -PathType Leaf) { + return $adapterPath + } + + return $null +} + # ============================================================================= # Environment Setup # ============================================================================= @@ -653,6 +687,16 @@ try { $vstestArgs += "/TestAdapterPath:$adapterPath" } + if ($Coverage) { + $coverageAdapterPath = Get-CoverageAdapterPath -RepoRoot $PSScriptRoot + if ($coverageAdapterPath) { + $vstestArgs += "/TestAdapterPath:$coverageAdapterPath" + } + else { + Write-Host "[WARN] -Coverage requested but coverlet.collector build folder was not found under packages/coverlet.collector. Run a build first so it restores." -ForegroundColor Yellow + } + } + # Logger configuration - verbosity goes with the console logger $verbosityMap = @{ 'quiet' = 'quiet'; 'q' = 'quiet' @@ -672,6 +716,10 @@ try { $vstestArgs += "/ListTests" } + if ($Coverage) { + $vstestArgs += '/Collect:XPlat Code Coverage' + } + # ============================================================================= # Run Tests # ============================================================================= @@ -736,6 +784,9 @@ try { foreach ($adapterPath in $nunitAdapterPaths) { $singleArgs += "/TestAdapterPath:$adapterPath" } + if ($Coverage -and $coverageAdapterPath) { + $singleArgs += "/TestAdapterPath:$coverageAdapterPath" + } $singleArgs += "/Logger:trx;LogFileName=${dllName}_${timestamp}.trx" $singleArgs += "/Logger:console;verbosity=$vstestVerbosity" @@ -743,6 +794,10 @@ try { $singleArgs += "/TestCaseFilter:$TestFilter" } + if ($Coverage) { + $singleArgs += '/Collect:XPlat Code Coverage' + } + & $vstestPath $singleArgs 2>&1 | Tee-Object -Variable singleTestOutput $singleExitCode = $LASTEXITCODE if ($singleExitCode -ne 0 -and $overallExitCode -eq 0) { @@ -767,6 +822,37 @@ try { $script:testExitCode = $overallExitCode } + + # ============================================================================= + # Coverage report (only when -Coverage was requested) + # ============================================================================= + + if ($Coverage -and -not $ListTests) { + $coverageFiles = Get-ChildItem -Path $resultsDir -Filter "coverage.cobertura.xml" -Recurse -ErrorAction SilentlyContinue + if (-not $coverageFiles -or $coverageFiles.Count -eq 0) { + Write-Host "[WARN] -Coverage was requested but no coverage.cobertura.xml files were produced under $resultsDir." -ForegroundColor Yellow + } + else { + Write-Host "" + Write-Host "Generating coverage report from $($coverageFiles.Count) file(s)..." -ForegroundColor Cyan + $coverageReportDir = Join-Path $resultsDir "CoverageReport" + $coverageReportsGlob = Join-Path $resultsDir "*/coverage.cobertura.xml" + try { + & dotnet tool run reportgenerator "-reports:$coverageReportsGlob" "-targetdir:$coverageReportDir" "-reporttypes:TextSummary;Html" 2>&1 | + Where-Object { $_ -notmatch '^\d{4}-\d\d-\d\dT' } # drop ReportGenerator's timestamped progress lines + $summaryPath = Join-Path $coverageReportDir "Summary.txt" + if (Test-Path -LiteralPath $summaryPath) { + Write-Host "" + Get-Content -LiteralPath $summaryPath | Write-Host + Write-Host "" + Write-Host "Full HTML coverage report: $(Join-Path $coverageReportDir 'index.html')" -ForegroundColor Gray + } + } + catch { + Write-Host "[WARN] ReportGenerator failed (is '.config/dotnet-tools.json' restored? run 'dotnet tool restore'): $_" -ForegroundColor Yellow + } + } + } } } finally { From 8d6d6d88ae6f1c3b59ea995d46df30af64810dba Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 3 Jul 2026 15:03:53 -0400 Subject: [PATCH 2/3] Wire code coverage into CI and auto-restore ReportGenerator Add -Coverage to the CI test.ps1 invocation and upload the rendered summary/HTML report as a build artifact. Have test.ps1 run `dotnet tool restore` before invoking ReportGenerator so a fresh checkout (CI runners, new clones) doesn't just warn and skip the report because the local tool manifest was never restored. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/CI.yml | 10 +++++++++- test.ps1 | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 5d156c7443..a701722a40 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,9 +35,17 @@ jobs: id: test shell: powershell run: | - .\test.ps1 -Configuration Debug -NoBuild -TestFilter 'TestCategory!=LongRunning&TestCategory!=ByHand&TestCategory!=SmokeTest&TestCategory!=DesktopRequired' + .\test.ps1 -Configuration Debug -NoBuild -Coverage -TestFilter 'TestCategory!=LongRunning&TestCategory!=ByHand&TestCategory!=SmokeTest&TestCategory!=DesktopRequired' if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Upload coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: Output/Debug/TestResults/CoverageReport + if-no-files-found: warn + - name: Summarize native test results if: ${{ always() }} shell: powershell diff --git a/test.ps1 b/test.ps1 index ca7db3fb65..6026a24bbb 100644 --- a/test.ps1 +++ b/test.ps1 @@ -838,6 +838,9 @@ try { $coverageReportDir = Join-Path $resultsDir "CoverageReport" $coverageReportsGlob = Join-Path $resultsDir "*/coverage.cobertura.xml" try { + # Restore the local tool manifest first (cheap no-op once already restored) so a fresh + # checkout/CI runner doesn't just warn and skip the report. + & dotnet tool restore 2>&1 | Out-Null & dotnet tool run reportgenerator "-reports:$coverageReportsGlob" "-targetdir:$coverageReportDir" "-reporttypes:TextSummary;Html" 2>&1 | Where-Object { $_ -notmatch '^\d{4}-\d\d-\d\dT' } # drop ReportGenerator's timestamped progress lines $summaryPath = Join-Path $coverageReportDir "Summary.txt" From 0f78266c5eb29222f968ad0f173b53dd44aac78b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Wed, 8 Jul 2026 11:38:41 -0400 Subject: [PATCH 3/3] Serialize testhost execution when collecting coverage coverlet.collector's XPlat Code Coverage data collector instruments every module with a matching .pdb next to the test assembly. FieldWorks test projects share one Output/ folder rather than per-project bin folders, so with the default MaxCpuCount=0 (parallel testhosts), two testhosts raced to instrument/restore the same shared product DLL (e.g. XMLViews.dll), failing CI with "being used by another process". Force MaxCpuCount=1 in a coverage-only runsettings clone so coverage runs execute test assemblies serially. Co-Authored-By: Claude Sonnet 5 --- test.ps1 | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test.ps1 b/test.ps1 index 6026a24bbb..6f0a4ee3c9 100644 --- a/test.ps1 +++ b/test.ps1 @@ -191,6 +191,36 @@ function New-TestRunSettingsForAssertDialogMode { return $runSettingsPath } +function New-TestRunSettingsForCoverage { + param( + [string]$SourcePath, + [string]$Configuration, + [bool]$Coverage + ) + + if (-not $Coverage) { + return $SourcePath + } + + # coverlet.collector's "XPlat Code Coverage" data collector instruments every module with a + # matching .pdb next to the test assembly, and restores it afterward. FieldWorks test projects + # all share a single Output/ folder rather than per-project bin folders, so with + # the default MaxCpuCount=0 (parallel testhosts), two testhosts can end up instrumenting/restoring + # the same shared product DLL (e.g. XMLViews.dll) at the same time, which fails with + # "being used by another process". Force serial testhost execution only for coverage runs. + [xml]$runSettings = Get-Content -LiteralPath $SourcePath -Raw + $runSettings.RunSettings.RunConfiguration.MaxCpuCount = '1' + + $runSettingsDir = Join-Path $PSScriptRoot "Output/$Configuration" + if (-not (Test-Path $runSettingsDir)) { + New-Item -ItemType Directory -Force -Path $runSettingsDir | Out-Null + } + + $runSettingsPath = Join-Path $runSettingsDir 'Test.coverage.runsettings' + $runSettings.Save($runSettingsPath) + return $runSettingsPath +} + $allowAssertDialogsForRun = $AllowAssertDialogs -or (Test-EnvironmentSwitchEnabled -Name 'FW_TEST_ALLOW_ASSERT_DIALOGS') function Add-UniquePath { @@ -675,6 +705,10 @@ try { -SourcePath $runSettingsSourcePath ` -Configuration $Configuration ` -AllowDialogs $allowAssertDialogsForRun + $runSettingsPath = New-TestRunSettingsForCoverage ` + -SourcePath $runSettingsPath ` + -Configuration $Configuration ` + -Coverage $Coverage $vstestArgs = @() $vstestArgs += $testDlls