Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/fieldworks-test-coverage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ You will receive:
- 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` restores the local dotnet tool manifest automatically (`dotnet-coverage` collects the raw `coverage.cobertura.xml`; ReportGenerator renders the summary/HTML). On PRs, Codecov reports the same data as a patch-coverage check.
- `test.ps1 -Coverage` also covers the native C++ tests (`Src/Generic`, `Src/views`): the Unit++ executables run under OpenCppCoverage (debugger-based, needs only PDBs, never rewrites binaries in the shared Output folder), writing `Output/<Configuration>/TestResults/native.<exe>.cobertura.xml`, which ReportGenerator accepts alongside the managed file. OpenCppCoverage must be installed (`choco install opencppcoverage`, or set env `OpenCppCoveragePath` to a portable copy) -- without it the native tests run bare with a `[WARN]` and no native coverage is produced, while managed coverage still collects. Native overhead is ~4x wall time (TestViews ~5s bare, ~20s covered). CI uploads native data under the Codecov flag `native`, separate from `managed`.
</notes>
59 changes: 59 additions & 0 deletions .github/actions/codecov-upload/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Codecov upload with retry
description: >-
Upload coverage files to Codecov, retrying the whole invocation once after a
backoff. The Codecov CLI retries individual HTTP requests, but
whole-invocation failures (503 storms, commit-creation errors) slip past
fail_ci_if_error, so the final step fails the job when neither attempt
succeeded.
inputs:
token:
description: Codecov upload token
required: true
files:
description: Comma-separated coverage files to upload
required: true
flags:
description: Codecov flag for this upload
required: true
name:
description: Codecov upload name
required: true

runs:
using: composite
steps:
- name: Upload to Codecov (attempt 1)
id: attempt1
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ inputs.token }}
fail_ci_if_error: true
files: ${{ inputs.files }}
flags: ${{ inputs.flags }}
name: ${{ inputs.name }}

- name: Wait before Codecov retry
if: ${{ steps.attempt1.outcome == 'failure' }}
shell: powershell
run: Start-Sleep -Seconds 30

- name: Upload to Codecov (attempt 2)
id: attempt2
if: ${{ steps.attempt1.outcome == 'failure' }}
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ inputs.token }}
fail_ci_if_error: true
files: ${{ inputs.files }}
flags: ${{ inputs.flags }}
name: ${{ inputs.name }}

- name: Verify Codecov upload succeeded
shell: powershell
run: |
$outcomes = @('${{ steps.attempt1.outcome }}', '${{ steps.attempt2.outcome }}')
if ($outcomes -contains 'success') { Write-Host 'Coverage uploaded to Codecov.'; exit 0 }
Write-Error 'Codecov upload failed after 2 attempts.'
exit 1
47 changes: 16 additions & 31 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,53 +31,38 @@ jobs:
.\build.ps1 -Configuration Debug -BuildTests
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

# Native tests run under OpenCppCoverage inside the test step below; without the tool they
# would fall back to a bare run and the native Codecov upload would fail on missing files.
- name: Install OpenCppCoverage
shell: powershell
run: |
choco install opencppcoverage --version=0.9.9.0 -y --no-progress
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

- name: Run tests
id: test
shell: powershell
run: |
.\test.ps1 -Configuration Debug -NoBuild -Coverage -TestFilter 'TestCategory!=LongRunning&TestCategory!=ByHand&TestCategory!=SmokeTest&TestCategory!=DesktopRequired'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

# The Codecov CLI retries individual HTTP requests, but whole-invocation failures (503 storms,
# commit-creation errors) slip past it. Add one step-level retry after a backoff; the gate step
# below fails CI if neither attempt succeeds.
- name: Upload coverage to Codecov (attempt 1)
id: codecov1
- name: Upload coverage to Codecov
if: ${{ !cancelled() }}
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: ./.github/actions/codecov-upload
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
files: Output/Debug/TestResults/CoverageReport/Cobertura.xml
flags: managed
name: FieldWorks

- name: Wait before Codecov retry
if: ${{ !cancelled() && steps.codecov1.outcome == 'failure' }}
shell: powershell
run: Start-Sleep -Seconds 30

- name: Upload coverage to Codecov (attempt 2)
id: codecov2
if: ${{ !cancelled() && steps.codecov1.outcome == 'failure' }}
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
- name: Upload native coverage to Codecov
if: ${{ !cancelled() }}
uses: ./.github/actions/codecov-upload
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
files: Output/Debug/TestResults/CoverageReport/Cobertura.xml
flags: managed
name: FieldWorks

- name: Verify Codecov upload succeeded
if: ${{ !cancelled() }}
shell: powershell
run: |
$outcomes = @('${{ steps.codecov1.outcome }}', '${{ steps.codecov2.outcome }}')
if ($outcomes -contains 'success') { Write-Host 'Coverage uploaded to Codecov.'; exit 0 }
Write-Error 'Codecov upload failed after 2 attempts.'
exit 1
files: Output/Debug/TestResults/native.testGenericLib.cobertura.xml,Output/Debug/TestResults/native.TestViews.cobertura.xml
flags: native
name: FieldWorks-native

- name: Summarize native test results
if: ${{ always() }}
Expand Down
147 changes: 146 additions & 1 deletion Build/Agent/FwBuildHelpers.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,146 @@ function Test-ViewsNativeArtifactsStale {
return $latestSource -gt $oldestArtifact
}

function Set-TestAssertDialogEnvironment {
<#!
.SYNOPSIS
Sets the DebugProcs.dll assertion environment variables for a test run.
.DESCRIPTION
By default puts asserts into unattended mode (converted to exceptions, no MessageBox)
so an assert cannot block a headless run. Pass -AllowDialogs $true to get interactive
Abort/Retry/Ignore dialogs for local debugger attachment.
#>
param(
[bool]$AllowDialogs
)

if ($AllowDialogs) {
$env:AssertUiEnabled = 'true'
$env:AssertExceptionEnabled = 'false'
$env:FW_TEST_MODE = '0'
$env:FW_TEST_ALLOW_ASSERT_DIALOGS = '1'
return
}

$env:AssertUiEnabled = 'false'
$env:AssertExceptionEnabled = 'true'
# Unconditional test-mode override: bypasses the registry AssertMessageBox key in DebugProcs.dll.
$env:FW_TEST_MODE = '1'
}

function Disable-CrashDialog {
<#!
.SYNOPSIS
Suppresses Windows Error Reporting and crash/critical-error dialogs for this process
and its children.
#>
# SEM_FAILCRITICALERRORS (0x1) | SEM_NOGPFAULTERRORBOX (0x2) | SEM_NOOPENFILEERRORBOX (0x8000)
if (-not ('FwBuildHelpers.NativeMethods' -as [type])) {
Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetErrorMode(uint uMode);' `
-Name 'NativeMethods' -Namespace 'FwBuildHelpers'
}
[void][FwBuildHelpers.NativeMethods]::SetErrorMode(0x8003)
}

function Get-UnitppSummary {
<#!
.SYNOPSIS
Parses the last Unit++ "Tests [Ok-Fail-Error]: [n-n-n]" summary line from test output.
.DESCRIPTION
Returns an object with Ok/Fail/Error counts and the matched Text, or $null when the
output contains no summary line (crash before completion, empty log).
#>
param(
[string[]]$LogContent
)

$summaryLine = $LogContent |
Select-String -Pattern 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]' |
Select-Object -Last 1
if (-not $summaryLine) {
return $null
}

$match = [regex]::Match($summaryLine.Line, 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]')
return [pscustomobject]@{
Ok = [int]$match.Groups[1].Value
Fail = [int]$match.Groups[2].Value
Error = [int]$match.Groups[3].Value
Text = $match.Value
}
}

function Find-OpenCppCoverage {
<#!
.SYNOPSIS
Returns the full path to OpenCppCoverage.exe, or $null when it is not installed.
.DESCRIPTION
Checks the OpenCppCoveragePath environment variable (for portable installs), then PATH,
then the default install location (C:\Program Files\OpenCppCoverage).
#>
if ($env:OpenCppCoveragePath -and (Test-Path $env:OpenCppCoveragePath)) {
return $env:OpenCppCoveragePath
}

$command = Get-Command 'OpenCppCoverage.exe' -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}

$defaultPath = 'C:\Program Files\OpenCppCoverage\OpenCppCoverage.exe'
if (Test-Path $defaultPath) {
return $defaultPath
}

return $null
}

function Convert-CoberturaPathsToRepoRelative {
<#!
.SYNOPSIS
Rewrites an OpenCppCoverage Cobertura export to repo-relative class filenames with
<source> pointing at the repo root.
.DESCRIPTION
OpenCppCoverage exports absolute paths, whose prefix differs per checkout location.
Repo-relative filenames let Codecov match files against the repository tree on any
machine, while the absolute <source> keeps local ReportGenerator runs resolving sources.
#>
param(
[Parameter(Mandatory)][string]$CoberturaFile,
[Parameter(Mandatory)][string]$RepoRoot
)

# OpenCppCoverage splits an absolute path into <source>C:</source> plus a driveless filename.
$repoRootNoDrive = ($RepoRoot -replace '^[A-Za-z]:', '').TrimStart('\') + '\'

$xml = [xml](Get-Content -Path $CoberturaFile -Raw)
$unmatched = 0
foreach ($class in $xml.SelectNodes('//class')) {
$fileName = $class.GetAttribute('filename')
if ($fileName.StartsWith($repoRootNoDrive, [System.StringComparison]::OrdinalIgnoreCase)) {
$class.SetAttribute('filename', $fileName.Substring($repoRootNoDrive.Length))
}
else {
$unmatched++
}
}
if ($unmatched -gt 0) {
Write-Host "[WARN] $unmatched class filename(s) in $CoberturaFile were not under the repo root and were left absolute." -ForegroundColor Yellow
}

$sourcesNode = $xml.SelectSingleNode('/coverage/sources')
if ($sourcesNode) {
while ($sourcesNode.HasChildNodes) {
[void]$sourcesNode.RemoveChild($sourcesNode.FirstChild)
}
$sourceNode = $xml.CreateElement('source')
$sourceNode.InnerText = $RepoRoot
[void]$sourcesNode.AppendChild($sourceNode)
}

$xml.Save($CoberturaFile)
}

# =============================================================================
# Module Exports
# =============================================================================
Expand All @@ -623,5 +763,10 @@ Export-ModuleMember -Function @(
'Invoke-WithFileLockRetry',
'Get-NewestWriteTimeUtc',
'Get-OldestWriteTimeUtc',
'Test-ViewsNativeArtifactsStale'
'Test-ViewsNativeArtifactsStale',
'Set-TestAssertDialogEnvironment',
'Disable-CrashDialog',
'Get-UnitppSummary',
'Find-OpenCppCoverage',
'Convert-CoberturaPathsToRepoRelative'
)
Loading
Loading