diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e7de3e1fb9..439057d70b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -327,12 +327,59 @@ jobs: name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} path: artifacts\GVFS.Installers + coverage_build: + runs-on: windows-2025 + name: Build AOT Coverage Probe + needs: validate + timeout-minutes: 30 + + env: + AotMsCodeCoverageInstrumentation: 'true' + MsCodeCoverageSessionId: gvfs-ci-${{ github.run_id }} + + steps: + - name: Checkout source + if: needs.validate.outputs.skip == '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: src + persist-credentials: false + + - name: Install .NET SDK + if: needs.validate.outputs.skip == '' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + dotnet-version: 8.0.x + + - name: Add MSBuild to PATH + if: needs.validate.outputs.skip == '' + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 + + - name: Build instrumented x64 installer + if: needs.validate.outputs.skip == '' + shell: cmd + run: src\scripts\Build.bat Release 0.2.173.2 minimal x64 + + - name: Create build artifacts + if: needs.validate.outputs.skip == '' + shell: cmd + run: src\scripts\CreateBuildArtifacts.bat Release artifacts x64 + + - name: Upload coverage-probe installer + if: needs.validate.outputs.skip == '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: GVFS_Coverage_Release_x64 + path: artifacts\GVFS.Installers + functional_tests: name: Functional Tests - needs: [validate, build] + needs: [validate, build, coverage_build] uses: ./.github/workflows/functional-tests.yaml with: skip: ${{ needs.validate.outputs.skip }} + aot_coverage_probe: true upgrade_tests: name: Upgrade Tests diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 5b05a0f1a0..85a5ed2858 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -38,6 +38,11 @@ on: required: false type: string default: '' + aot_coverage_probe: + description: 'Use the instrumented installer and collect coverage on x64 shard 0' + required: false + type: boolean + default: false secrets: vfs_token: description: 'Token for downloading VFSForGit artifacts (required for cross-repository downloads; defaults to GITHUB_TOKEN)' @@ -58,6 +63,8 @@ jobs: env: ARTIFACT_PREFIX: ${{ inputs.output_prefix && format('{0}_', inputs.output_prefix) || '' }} FT_MATRIX_NAME: ${{ format('{0}_{1}-{2}', matrix.configuration, matrix.architecture, matrix.nr) }} + AOT_COVERAGE_PROBE: ${{ inputs.aot_coverage_probe && matrix.architecture == 'x64' && matrix.nr == 0 }} + COVERAGE_SESSION_ID: gvfs-ci-${{ inputs.vfs_run_id || github.run_id }} strategy: matrix: @@ -104,7 +111,7 @@ jobs: continue-on-error: true uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} + name: ${{ env.AOT_COVERAGE_PROBE == 'true' && 'GVFS_Coverage_Release_x64' || format('GVFS_{0}_{1}', matrix.configuration, matrix.architecture) }} path: gvfs repository: ${{ inputs.vfs_repository || github.repository }} run-id: ${{ inputs.vfs_run_id || github.run_id }} @@ -114,7 +121,7 @@ jobs: if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} + name: ${{ env.AOT_COVERAGE_PROBE == 'true' && 'GVFS_Coverage_Release_x64' || format('GVFS_{0}_{1}', matrix.configuration, matrix.architecture) }} path: gvfs repository: ${{ inputs.vfs_repository || github.repository }} run-id: ${{ inputs.vfs_run_id || github.run_id }} @@ -153,6 +160,40 @@ jobs: run-id: ${{ inputs.vfs_run_id || github.run_id }} github-token: ${{ secrets.vfs_token || github.token }} + - name: Checkout coverage configuration and source + if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: src + persist-credentials: false + + - name: Install coverage runtime and SDK + if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + dotnet-version: 8.0.x + + - name: Install coverage tools + if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + shell: pwsh + run: | + dotnet tool install dotnet-coverage --version 18.8.0 --tool-path coverage-tools --configfile src\nuget.config + if ($LASTEXITCODE -ne 0) { throw 'Coverage collector installation failed.' } + dotnet tool install dotnet-reportgenerator-globaltool --version 5.5.11 --tool-path coverage-tools --configfile src\nuget.config + if ($LASTEXITCODE -ne 0) { throw 'Coverage renderer installation failed.' } + New-Item -ItemType Directory -Path coverage -Force | Out-Null + + - name: Start fixed-session coverage before service installation + id: coverage_start + if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + shell: pwsh + run: | + .\coverage-tools\dotnet-coverage.exe collect --server-mode --background --session-id $env:COVERAGE_SESSION_ID --settings src\scripts\AotCoverage.config --output coverage\functional.cobertura.xml --output-format cobertura --log-file coverage\collector.log --log-level Verbose + if ($LASTEXITCODE -ne 0) { throw 'Coverage collector startup failed.' } + .\coverage-tools\dotnet-coverage.exe snapshot $env:COVERAGE_SESSION_ID --output coverage\before-install.coverage + if ($LASTEXITCODE -ne 0) { throw 'Coverage collector readiness check failed.' } + - name: ProjFS details (pre-install) if: steps.skip.outputs.result != 'true' shell: cmd @@ -197,6 +238,14 @@ jobs: git\logs gvfs\logs + - name: Record installed service identity + if: steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + shell: pwsh + run: | + Get-CimInstance Win32_Service -Filter "Name = 'GVFS.Service'" | + Select-Object Name,StartName,State,ProcessId,PathName | + ConvertTo-Json | Set-Content coverage\installed-service.json + - name: Run functional tests if: steps.skip.outputs.result != 'true' shell: cmd @@ -207,6 +256,33 @@ jobs: SET GVFS_TEST_DIAGNOSTICS_DIR=C:\temp\gvfs-ft-diagnostics ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci --slice=${{ matrix.nr }},12 + - name: Stop coverage and verify SCM service capture + id: coverage_stop + if: always() && env.AOT_COVERAGE_PROBE == 'true' && steps.coverage_start.outcome == 'success' + shell: pwsh + run: | + .\coverage-tools\dotnet-coverage.exe shutdown $env:COVERAGE_SESSION_ID + if ($LASTEXITCODE -ne 0) { throw 'Coverage collector shutdown failed.' } + src\scripts\Assert-AotCoverage.ps1 -CoveragePath coverage\functional.cobertura.xml | + Tee-Object -FilePath coverage\probe.json + + - name: Render single-shard coverage report + if: always() && env.AOT_COVERAGE_PROBE == 'true' && steps.coverage_stop.outcome == 'success' + shell: pwsh + run: | + .\coverage-tools\reportgenerator.exe -reports:coverage\functional.cobertura.xml -targetdir:coverage\report "-reporttypes:Html;MarkdownSummaryGithub;Cobertura;JsonSummary" "-filefilters:+$env:GITHUB_WORKSPACE\src\GVFS\*" "-title:Managed NativeAOT product - x64 shard 0 of 12" + if ($LASTEXITCODE -ne 0) { throw 'Coverage rendering failed.' } + 'One-shard probe only. Native C++ and the other shards are not measured.' >> $env:GITHUB_STEP_SUMMARY + Get-Content coverage\report\SummaryGithub.md >> $env:GITHUB_STEP_SUMMARY + + - name: Upload coverage-probe evidence + if: always() && steps.skip.outputs.result != 'true' && env.AOT_COVERAGE_PROBE == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ env.ARTIFACT_PREFIX }}AotCoverage_${{ env.FT_MATRIX_NAME }} + path: coverage + if-no-files-found: error + - name: Upload failure diagnostics (mount dumps + logs) if: always() && steps.skip.outputs.result != 'true' uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/Directory.Build.targets b/Directory.Build.targets index 59c793d2d4..7aa29b1094 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -12,6 +12,15 @@ + + + true + + + + + + + diff --git a/scripts/AotCoverage.config b/scripts/AotCoverage.config new file mode 100644 index 0000000000..7b3c03e238 --- /dev/null +++ b/scripts/AotCoverage.config @@ -0,0 +1,11 @@ + + + False + + False + False + False + False + True + + diff --git a/scripts/Assert-AotCoverage.Tests.ps1 b/scripts/Assert-AotCoverage.Tests.ps1 new file mode 100644 index 0000000000..537fb088d7 --- /dev/null +++ b/scripts/Assert-AotCoverage.Tests.ps1 @@ -0,0 +1,78 @@ +BeforeAll { + $assertion = Join-Path $PSScriptRoot 'Assert-AotCoverage.ps1' + + function New-CoverageReport { + param( + [int]$OnStartHits = 1, + [int]$ConsoleHits = 0 + ) + + $modules = @('GVFS', 'GVFS.Hooks', 'GVFS.Mount', 'GVFS.Service', 'GVFS.Common', 'GVFS.Platform.Windows', 'GVFS.Virtualization') + $packages = ($modules | ForEach-Object { + "" + }) -join '' + [xml]$document = "$packages" + $service = $document.SelectSingleNode("//package[@name='GVFS.Service']/classes") + $service.InnerXml = @" + + + + + + + + +"@ + return ,$document + } + + function Save-CoverageReport { + param([xml]$Document) + $path = Join-Path $TestDrive 'coverage.xml' + $Document.Save($path) + return $path + } +} + +Describe 'NativeAOT SCM coverage evidence' { + It 'Accepts product coverage with SCM startup and no console-service entry' { + $path = Save-CoverageReport (New-CoverageReport) + $result = & $assertion -CoveragePath $path | ConvertFrom-Json + $result.ScmServiceStartupCovered | Should -BeTrue + $result.CompleteSuiteCoverage | Should -BeFalse + } + + It 'Rejects a report without SCM startup execution' { + $path = Save-CoverageReport (New-CoverageReport -OnStartHits 0) + { & $assertion -CoveragePath $path } | Should -Throw '*no executed GVFSService.OnStart*' + } + + It 'Rejects console-service execution even when SCM startup also executes' { + $path = Save-CoverageReport (New-CoverageReport -ConsoleHits 1) + { & $assertion -CoveragePath $path } | Should -Throw '*Console-service execution contaminates*' + } + + It 'Rejects missing product modules' { + $document = New-CoverageReport + $node = $document.SelectSingleNode("//package[@name='GVFS.Mount']") + [void]$node.ParentNode.RemoveChild($node) + $path = Save-CoverageReport $document + { & $assertion -CoveragePath $path } | Should -Throw '*Expected one coverage module for GVFS.Mount*' + } + + It 'Rejects product modules with metadata but no executed lines' { + $document = New-CoverageReport + $document.SelectSingleNode("//package[@name='GVFS.Hooks']//line").SetAttribute('hits', '0') + $path = Save-CoverageReport $document + { & $assertion -CoveragePath $path } | Should -Throw '*No executed lines in required product module GVFS.Hooks*' + } + + It 'Rejects unit-copy coverage in a product report' { + $document = New-CoverageReport + $node = $document.CreateElement('package') + $node.SetAttribute('name', 'GVFS.UnitTests') + [void]$document.coverage.packages.AppendChild($node) + $path = Save-CoverageReport $document + { & $assertion -CoveragePath $path } | Should -Throw '*Test assemblies contaminate*' + } +} diff --git a/scripts/Assert-AotCoverage.ps1 b/scripts/Assert-AotCoverage.ps1 new file mode 100644 index 0000000000..86ef5a1d56 --- /dev/null +++ b/scripts/Assert-AotCoverage.ps1 @@ -0,0 +1,61 @@ +param( + [Parameter(Mandatory)] + [string]$CoveragePath +) + +$ErrorActionPreference = 'Stop' + +[xml]$coverage = Get-Content -LiteralPath $CoveragePath -Raw +$packages = @($coverage.coverage.packages.package) +$requiredModules = @( + 'GVFS', + 'GVFS.Hooks', + 'GVFS.Mount', + 'GVFS.Service', + 'GVFS.Common', + 'GVFS.Platform.Windows', + 'GVFS.Virtualization' +) + +foreach ($name in $requiredModules) { + $module = @($packages | Where-Object { $_.name -eq $name }) + if ($module.Count -ne 1) { + throw "Expected one coverage module for $name; found $($module.Count)." + } + + $hitLines = @($module[0].classes.class.lines.line | Where-Object { [int]$_.hits -gt 0 }) + if ($hitLines.Count -eq 0) { + throw "No executed lines in required product module $name." + } +} + +$testModules = @($packages | Where-Object { $_.name -match '(UnitTests|FunctionalTests|^GVFS.Tests$)' }) +if ($testModules.Count -ne 0) { + throw "Test assemblies contaminate the product report: $($testModules.name -join ', ')." +} + +$service = $packages | Where-Object { $_.name -eq 'GVFS.Service' } +$serviceClass = $service.classes.class | Where-Object { $_.name -eq 'GVFS.Service.GVFSService' } +$onStart = @($serviceClass.methods.method | Where-Object { $_.name -eq 'OnStart' }) +$startupHits = @($onStart.lines.line | Where-Object { [int]$_.hits -gt 0 }) +if ($startupHits.Count -eq 0) { + throw 'The report contains no executed GVFSService.OnStart lines.' +} + +$program = $service.classes.class | Where-Object { $_.name -eq 'GVFS.Service.Program' } +$consoleEntry = @($program.methods.method | Where-Object { $_.name -eq 'RunAsConsoleApp' }) +$consoleHits = @($consoleEntry.lines.line | Where-Object { [int]$_.hits -gt 0 }) +if ($consoleHits.Count -ne 0) { + throw 'Console-service execution contaminates the SCM-service probe.' +} + +[ordered]@{ + Scope = 'Managed NativeAOT product; x64 shard 0 of 12' + Modules = $packages.name + ScmServiceStartupCovered = $true + ConsoleServiceEntryCovered = $false + NativeCppCovered = $false + CompleteSuiteCoverage = $false + CoveredLines = [int]$coverage.coverage.'lines-covered' + MeasuredLines = [int]$coverage.coverage.'lines-valid' +} | ConvertTo-Json -Depth 3