Files
git.stella-ops.org/scripts/test-targeted-xunit.ps1
master 2e35bf4591 fix(tools,concelier): xunit helper strict-mode + test async disposal
- scripts/test-targeted-xunit.ps1: replace @(x).Count checks with
  [bool] coercion in Assert-FilterShape; StrictMode 'Latest' rejects
  .Count on null even when wrapped in @().
- ConcelierInfrastructureRegistrationTests.AddConcelierPostgresStorage_
  RegistersDurableObservationAndAffectedSymbolServices: wrap provider
  in try/finally with DisposeAsync — ConcelierDataSource is
  IAsyncDisposable only, so sync Dispose at `using` scope end throws.

Follow-up to SPRINT_20260419_027/028.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:57:24 +03:00

289 lines
8.4 KiB
PowerShell

#!/usr/bin/env pwsh
<#
.SYNOPSIS
Runs targeted xUnit v3 tests by executing the compiled test DLL directly.
.DESCRIPTION
Some Stella Ops test projects run under Microsoft Testing Platform with the
xUnit v3 in-process runner. In that mode, `dotnet test --filter` may be
ignored, which makes targeted verification misleading. This helper rebuilds
the requested test project if needed, resolves the output test assembly, and
invokes `dotnet exec <test-dll>` with xUnit's native filter arguments.
.PARAMETER Project
Path to the test `.csproj`.
.PARAMETER Method
Fully qualified test method to run. May be specified more than once.
.PARAMETER Class
Fully qualified test class to run. May be specified more than once.
.PARAMETER Namespace
Namespace filter to run. May be specified more than once.
.PARAMETER Trait
xUnit trait filter in `name=value` form. May be specified more than once.
.PARAMETER QueryFilter
xUnit query filter string. Do not combine with `Method`, `Class`, `Namespace`,
or `Trait`.
.PARAMETER Framework
Target framework. Required when the project uses `TargetFrameworks`.
.PARAMETER Configuration
Build configuration. Defaults to `Debug`.
.PARAMETER Reporter
xUnit reporter to use. Defaults to `verbose`.
.PARAMETER SkipBuild
Skip the project build and run the existing test DLL.
.PARAMETER BuildProjectReferences
Whether to build project references. Defaults to `$false` to keep targeted
verification fast and avoid broad graph rebuilds when outputs are already
available.
.PARAMETER AdditionalRunnerArgument
Additional raw xUnit runner arguments appended after the generated filters.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Project,
[string[]]$Method = @(),
[string[]]$Class = @(),
[string[]]$Namespace = @(),
[string[]]$Trait = @(),
[string]$QueryFilter,
[string]$Framework,
[string]$Configuration = 'Debug',
[string]$Reporter = 'verbose',
[switch]$SkipBuild,
[bool]$BuildProjectReferences = $false,
[string[]]$AdditionalRunnerArgument = @()
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-RepoRoot {
return Split-Path -Parent $PSScriptRoot
}
function Resolve-ProjectPath {
param(
[Parameter(Mandatory)]
[string]$InputPath,
[Parameter(Mandatory)]
[string]$RepoRoot
)
if ([System.IO.Path]::IsPathRooted($InputPath)) {
return (Resolve-Path -LiteralPath $InputPath).ProviderPath
}
return (Resolve-Path -LiteralPath (Join-Path $RepoRoot $InputPath)).ProviderPath
}
function Get-ProjectMetadata {
param(
[Parameter(Mandatory)]
[string]$ProjectPath
)
[xml]$projectXml = Get-Content -LiteralPath $ProjectPath -Raw
$propertyGroups = @($projectXml.Project.PropertyGroup)
$assemblyName = $null
$targetFramework = $null
$targetFrameworks = $null
foreach ($group in $propertyGroups) {
$assemblyNameNode = $group.SelectSingleNode('AssemblyName')
if (-not $assemblyName -and $assemblyNameNode -and -not [string]::IsNullOrWhiteSpace($assemblyNameNode.InnerText)) {
$assemblyName = $assemblyNameNode.InnerText.Trim()
}
$targetFrameworkNode = $group.SelectSingleNode('TargetFramework')
if (-not $targetFramework -and $targetFrameworkNode -and -not [string]::IsNullOrWhiteSpace($targetFrameworkNode.InnerText)) {
$targetFramework = $targetFrameworkNode.InnerText.Trim()
}
$targetFrameworksNode = $group.SelectSingleNode('TargetFrameworks')
if (-not $targetFrameworks -and $targetFrameworksNode -and -not [string]::IsNullOrWhiteSpace($targetFrameworksNode.InnerText)) {
$targetFrameworks = $targetFrameworksNode.InnerText.Trim()
}
}
if (-not $assemblyName) {
$assemblyName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectPath)
}
return [pscustomobject]@{
AssemblyName = $assemblyName
TargetFramework = $targetFramework
TargetFrameworks = $targetFrameworks
}
}
function Resolve-Framework {
param(
[Parameter(Mandatory)]
$Metadata,
[string]$RequestedFramework,
[Parameter(Mandatory)]
[string]$ProjectPath
)
if ($RequestedFramework) {
return $RequestedFramework
}
if ($Metadata.TargetFramework) {
return $Metadata.TargetFramework
}
if ($Metadata.TargetFrameworks) {
$frameworks = @($Metadata.TargetFrameworks.Split(';', [System.StringSplitOptions]::RemoveEmptyEntries))
if ($frameworks.Count -eq 1) {
return $frameworks[0].Trim()
}
throw "Project declares multiple target frameworks ($($Metadata.TargetFrameworks)). Specify -Framework explicitly."
}
throw "Could not determine a target framework from '$ProjectPath'. Specify -Framework explicitly."
}
function Assert-FilterShape {
param(
[string[]]$MethodFilters,
[string[]]$ClassFilters,
[string[]]$NamespaceFilters,
[string[]]$TraitFilters,
[string]$Query
)
$hasSimpleFilters = ([bool]$MethodFilters) -or
([bool]$ClassFilters) -or
([bool]$NamespaceFilters) -or
([bool]$TraitFilters)
if (-not $hasSimpleFilters -and [string]::IsNullOrWhiteSpace($Query)) {
throw 'Specify at least one filter via -Method, -Class, -Namespace, -Trait, or -QueryFilter.'
}
if ($hasSimpleFilters -and -not [string]::IsNullOrWhiteSpace($Query)) {
throw 'Do not mix -QueryFilter with -Method/-Class/-Namespace/-Trait.'
}
}
function Expand-FilterValues {
param(
[string[]]$Values
)
$expanded = New-Object System.Collections.Generic.List[string]
foreach ($value in $Values) {
if ([string]::IsNullOrWhiteSpace($value)) {
continue
}
foreach ($segment in $value.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries)) {
$trimmed = $segment.Trim()
if (-not [string]::IsNullOrWhiteSpace($trimmed)) {
$expanded.Add($trimmed)
}
}
}
return @($expanded)
}
$repoRoot = Resolve-RepoRoot
$projectPath = Resolve-ProjectPath -InputPath $Project -RepoRoot $repoRoot
$metadata = Get-ProjectMetadata -ProjectPath $projectPath
$resolvedFramework = Resolve-Framework -Metadata $metadata -RequestedFramework $Framework -ProjectPath $projectPath
$Method = Expand-FilterValues -Values $Method
$Class = Expand-FilterValues -Values $Class
$Namespace = Expand-FilterValues -Values $Namespace
$Trait = Expand-FilterValues -Values $Trait
Assert-FilterShape -MethodFilters $Method -ClassFilters $Class -NamespaceFilters $Namespace -TraitFilters $Trait -Query $QueryFilter
$projectDirectory = Split-Path -Parent $projectPath
$assemblyPath = Join-Path $projectDirectory "bin\$Configuration\$resolvedFramework\$($metadata.AssemblyName).dll"
if (-not $SkipBuild) {
$buildProjectReferencesValue = if ($BuildProjectReferences) { 'true' } else { 'false' }
$buildArguments = @(
'build',
$projectPath,
'--no-restore',
'--nologo',
'--disable-build-servers',
'-v', 'minimal',
'-c', $Configuration,
('-p:BuildProjectReferences=' + $buildProjectReferencesValue),
'/m:1'
)
if ($resolvedFramework) {
$buildArguments += @('-f', $resolvedFramework)
}
Write-Host "Building $projectPath ($resolvedFramework, $Configuration)..."
& dotnet @buildArguments
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
}
if (-not (Test-Path -LiteralPath $assemblyPath)) {
throw "Compiled test assembly not found: $assemblyPath"
}
$runnerArguments = @(
'exec',
$assemblyPath,
'-noLogo'
)
if (-not [string]::IsNullOrWhiteSpace($Reporter)) {
$runnerArguments += @('-reporter', $Reporter)
}
if (-not [string]::IsNullOrWhiteSpace($QueryFilter)) {
$runnerArguments += @('-filter', $QueryFilter)
}
foreach ($value in $Method) {
$runnerArguments += @('-method', $value)
}
foreach ($value in $Class) {
$runnerArguments += @('-class', $value)
}
foreach ($value in $Namespace) {
$runnerArguments += @('-namespace', $value)
}
foreach ($value in $Trait) {
$runnerArguments += @('-trait', $value)
}
foreach ($value in $AdditionalRunnerArgument) {
$runnerArguments += $value
}
Write-Host "Running targeted xUnit assembly $assemblyPath"
& dotnet @runnerArguments
exit $LASTEXITCODE